'POST', 'callback' => [$this, 'preview'], 'permission_callback' => static fn (\WP_REST_Request $request): bool => current_user_can('edit_pcwp_events') && (empty($request['context_id']) || (in_array(get_post_type((int) $request['context_id']), ['pcwp_event', 'pcwp_series'], true) && current_user_can('edit_post', (int) $request['context_id']) && ($request['scope'] !== 'following' || ! get_post_meta((int) $request['context_id'], '_pcwp_series_id', true) || current_user_can('edit_post', (int) get_post_meta((int) $request['context_id'], '_pcwp_series_id', true))))), ]); register_rest_route('pro-calendar-wp/v1', '/events/(?P\d+)/recurrence', [ 'methods' => 'POST', 'callback' => [$this, 'create'], 'permission_callback' => static fn (\WP_REST_Request $request): bool => current_user_can('edit_post', (int) $request['id']) && get_post_type((int) $request['id']) === 'pcwp_event' && current_user_can('edit_pcwp_events'), ]); register_rest_route('pro-calendar-wp/v1', '/events/(?P\d+)/series-scope-preview', [ 'methods' => 'POST', 'callback' => [$this, 'scopePreview'], 'permission_callback' => static fn (\WP_REST_Request $request): bool => get_post_type((int) $request['id']) === 'pcwp_event' && current_user_can('edit_post', (int) $request['id']) && current_user_can('edit_post', absint(get_post_meta((int) $request['id'], '_pcwp_series_id', true))), 'args' => ['scope' => ['type' => 'string', 'enum' => ['this', 'following', 'whole'], 'required' => true]], ]); register_rest_route('pro-calendar-wp/v1', '/series/(?P\d+)/approve-engine', [ 'methods' => 'POST', 'callback' => function (\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $id = (int) $request['id']; $validation = new \WP_REST_Request('POST'); $validation->set_body_params(['rule' => get_post_meta($id, SeriesMeta::RULE, true), 'start' => get_post_meta($id, '_pcwp_start', true), 'end' => get_post_meta($id, '_pcwp_end', true), 'timezone' => get_post_meta($id, '_pcwp_timezone', true), 'exclusions' => get_post_meta($id, SeriesMeta::EXCLUSIONS, true), 'additions' => get_post_meta($id, SeriesMeta::ADDITIONS, true)]); $result = $this->preview($validation); if (is_wp_error($result)) { return $result; } update_post_meta($id, '_pcwp_recurrence_engine', 'rrule-3'); $this->materializer->materialize($id); return new \WP_REST_Response(['series_id' => $id]); }, 'permission_callback' => static fn (\WP_REST_Request $request): bool => get_post_type((int) $request['id']) === 'pcwp_series' && current_user_can('edit_post', (int) $request['id']), ]); register_rest_route('pro-calendar-wp/v1', '/series/(?P\d+)/materialize', [ 'methods' => 'POST', 'callback' => [$this, 'materialize'], 'permission_callback' => static fn (\WP_REST_Request $request): bool => get_post_type((int) $request['id']) === 'pcwp_series' && current_user_can('edit_post', (int) $request['id']), ]); register_rest_route('pro-calendar-wp/v1', '/events/(?P\d+)/series-scope', [ 'methods' => 'POST', 'callback' => [$this, 'editScope'], 'permission_callback' => static fn (\WP_REST_Request $request): bool => get_post_type((int) $request['id']) === 'pcwp_event' && current_user_can('edit_post', (int) $request['id']), 'args' => [ 'scope' => ['type' => 'string', 'enum' => ['this', 'following', 'whole'], 'required' => true], 'changes' => ['type' => 'object', 'required' => true], ], ]); } public function preview(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { try { $time = \ProCalendarWP\Events\EventTime::fromStored((int) $request['context_id'], (string) $request['start'], (string) $request['end'], EventMeta::sanitizeTimezone($request['timezone']), true); $context = (int) $request['context_id']; $boundary = (string) get_post_meta($context, '_pcwp_recurrence_from', true); $parent = absint(get_post_meta($context, '_pcwp_series_id', true)); if ($parent > 0 && $request['scope'] === 'following' && ! $request['schedule_changed'] && $request['rule'] === get_post_meta($parent, SeriesMeta::RULE, true)) { $time = \ProCalendarWP\Events\EventTime::fromStored($parent, (string) get_post_meta($parent, '_pcwp_start', true), (string) get_post_meta($parent, '_pcwp_end', true), EventMeta::sanitizeTimezone(get_post_meta($parent, '_pcwp_timezone', true)), true); $boundary = (string) get_post_meta($context, '_pcwp_recurrence_id', true); } $dates = []; $today = new DateTimeImmutable('now', $time->start->getTimezone()); $from = $time->start > $today ? $time->start : $today; if ($boundary !== '') { $cutoff = new DateTimeImmutable($boundary); $from = $cutoff > $from ? $cutoff : $from; } $parts = (new Rfc5545Engine())->parse((string) $request['rule']); $years = min(200, max(40, 11 * (int) ($parts['INTERVAL'] ?? 1))); foreach ((new Rfc5545Engine())->expand((string) $request['rule'], $time->start, $time->start->diff($time->end), $from, $from->modify('+' . $years . ' years'), SeriesMeta::sanitizeDates($request['exclusions']), SeriesMeta::sanitizeDates($request['additions']), 10) as $occurrence) { $dates[] = ['start' => $occurrence->start->format(DATE_ATOM), 'end' => $occurrence->end->format(DATE_ATOM)]; if (count($dates) >= 10) { break; } } return new \WP_REST_Response(['dates' => $dates]); } catch (\InvalidArgumentException $error) { return new \WP_Error('pcwp_invalid_recurrence', $error->getMessage(), ['status' => 400]); } } public function scopePreview(\WP_REST_Request $request): \WP_REST_Response { $id = (int) $request['id']; $series = absint(get_post_meta($id, '_pcwp_series_id', true)); $query = ['post_type' => 'pcwp_event', 'post_status' => ['publish', 'draft', 'pending', 'future', 'private'], 'posts_per_page' => 10, 'meta_key' => '_pcwp_start', 'orderby' => 'meta_value', 'order' => 'ASC', 'meta_query' => [['key' => '_pcwp_series_id', 'value' => $series]]]; if ($request['scope'] === 'this') { $query['post__in'] = [$id]; } elseif ($request['scope'] === 'following') { $query['meta_query'][] = ['key' => '_pcwp_recurrence_id', 'value' => get_post_meta($id, '_pcwp_recurrence_id', true), 'compare' => '>=']; } $events = new \WP_Query($query); return new \WP_REST_Response(['count' => $events->found_posts, 'dates' => array_map(static fn (\WP_Post $event): string => (string) get_post_meta($event->ID, '_pcwp_start', true), $events->posts)]); } public function create(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { global $wpdb; $lock = 'pcwp_repeat_create_' . md5($wpdb->prefix . (int) $request['id']); if ((string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s,5)', $lock)) !== '1') { return new \WP_Error('pcwp_series_busy', __('Please try again in a moment.', 'proeventswp'), ['status' => 409]); } try { return $this->createLocked($request); } finally { $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); } } private function createLocked(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $id = (int) $request['id']; $source = get_post($id); $existing = absint(get_post_meta($id, '_pcwp_series_id', true)); if ($existing > 0) { return new \WP_REST_Response(['series_id' => $existing, 'event_id' => $id]); } if (! \ProCalendarWP\Infrastructure\Capabilities::canCreatePosts('pcwp_series') || ! \ProCalendarWP\Infrastructure\Capabilities::canCreatePosts()) { return new \WP_Error('pcwp_series_permission', __('You cannot create recurring events.', 'proeventswp'), ['status' => 403]); } if (! $source instanceof \WP_Post || (get_post_meta($id, '_pcwp_schedule', true)['type'] ?? '') === 'daily') { return new \WP_Error('pcwp_series_source', __('Choose a standalone continuous event.', 'proeventswp'), ['status' => 400]); } try { $time = \ProCalendarWP\Events\EventTime::fromStored($id, (string) get_post_meta($id, '_pcwp_start', true), (string) get_post_meta($id, '_pcwp_end', true), EventMeta::sanitizeTimezone(get_post_meta($id, '_pcwp_timezone', true)), true); iterator_to_array((new Rfc5545Engine())->expand((string) $request['rule'], $time->start, $time->start->diff($time->end), $time->start, $time->start->modify('+2 years'), SeriesMeta::sanitizeDates($request['exclusions']), SeriesMeta::sanitizeDates($request['additions']))); } catch (\InvalidArgumentException $error) { return new \WP_Error('pcwp_invalid_recurrence', $error->getMessage(), ['status' => 400]); } $series = wp_insert_post(['post_type' => 'pcwp_series', 'post_status' => $source->post_status, 'post_title' => $source->post_title, 'post_content' => $source->post_content, 'post_excerpt' => $source->post_excerpt, 'post_author' => (int) $source->post_author, 'post_password' => $source->post_password], true); if (is_wp_error($series)) { return $series; } foreach (EventMeta::definitions() as $key => $definition) { if (! in_array($key, ['_pcwp_series_id', '_pcwp_series_sequence', '_pcwp_series_overrides', '_pcwp_recurrence_id', '_pcwp_schedule'], true)) { update_post_meta($series, $key, get_post_meta($id, $key, true)); } } foreach (['pcwp_event_category', 'pcwp_event_tag'] as $taxonomy) { wp_set_object_terms($series, wp_get_object_terms($id, $taxonomy, ['fields' => 'ids']), $taxonomy); } $entities = (new \ProCalendarWP\Events\WpEventRepository())->find($id); update_post_meta($series, '_pcwp_venue_ids', $entities !== null ? $entities->venueIds : []); update_post_meta($series, '_pcwp_organizer_ids', $entities !== null ? $entities->organizerIds : []); set_post_thumbnail($series, get_post_thumbnail_id($id)); update_post_meta($series, SeriesMeta::RULE, SeriesMeta::sanitizeRule($request['rule'])); update_post_meta($series, SeriesMeta::EXCLUSIONS, SeriesMeta::sanitizeDates($request['exclusions'])); update_post_meta($series, SeriesMeta::ADDITIONS, SeriesMeta::sanitizeDates($request['additions'])); update_post_meta($series, '_pcwp_recurrence_engine', 'rrule-3'); update_post_meta($id, '_pcwp_recurrence_id', $time->start->format('Y-m-d H:i:sP')); update_post_meta($id, '_pcwp_series_id', $series); $this->materializer->materialize($series); return new \WP_REST_Response(['series_id' => $series, 'event_id' => $id], 201); } public function materialize(\WP_REST_Request $request): \WP_REST_Response { $seriesId = (int) $request['id']; $this->materializer->materialize($seriesId, 0); return new \WP_REST_Response(['series_id' => $seriesId, 'status' => 'started'], 202); } public function editScope(\WP_REST_Request $request): \WP_REST_Response|\WP_Error { $eventId = (int) $request['id']; $seriesId = absint(get_post_meta($eventId, '_pcwp_series_id', true)); if ($seriesId === 0) { return new \WP_Error('pcwp_not_series_event', __('This event does not belong to a series.', 'proeventswp'), ['status' => 400]); } $scope = (string) $request->get_param('scope'); if ($scope !== 'this' && ! current_user_can('edit_post', $seriesId)) { return new \WP_Error('pcwp_series_permission', __('You cannot edit this series.', 'proeventswp'), ['status' => 403]); } if ($scope === 'following' && ! \ProCalendarWP\Infrastructure\Capabilities::canCreatePosts('pcwp_series')) { return new \WP_Error('pcwp_series_permission', __('You cannot create a series.', 'proeventswp'), ['status' => 403]); } $changes = $request->get_param('changes'); if (! is_array($changes)) { return new \WP_Error('pcwp_invalid_changes', __('Invalid series changes.', 'proeventswp'), ['status' => 400]); } try { $target = $scope === 'whole' ? $seriesId : $eventId; $time = \ProCalendarWP\Events\EventTime::fromStored($target, (string) ($changes['start'] ?? get_post_meta($target, '_pcwp_start', true)), (string) ($changes['end'] ?? get_post_meta($target, '_pcwp_end', true)), EventMeta::sanitizeTimezone($changes['timezone'] ?? get_post_meta($target, '_pcwp_timezone', true)), true); if ($scope !== 'this') { iterator_to_array((new Rfc5545Engine())->expand((string) ($changes['recurrence_rule'] ?? get_post_meta($seriesId, SeriesMeta::RULE, true)), $time->start, $time->start->diff($time->end), $time->start, $time->start->modify('+2 years'), SeriesMeta::sanitizeDates($changes['recurrence_exclusions'] ?? get_post_meta($seriesId, SeriesMeta::EXCLUSIONS, true)), SeriesMeta::sanitizeDates($changes['recurrence_additions'] ?? get_post_meta($seriesId, SeriesMeta::ADDITIONS, true)))); } } catch (\InvalidArgumentException $error) { return new \WP_Error('pcwp_invalid_recurrence', $error->getMessage(), ['status' => 400]); } if ($scope === 'this') { $this->apply($eventId, $changes, true); return new \WP_REST_Response(['event_id' => $eventId, 'series_id' => $seriesId, 'scope' => $scope]); } if ($scope === 'following') { if (($changes['recurrence_rule'] ?? null) === get_post_meta($seriesId, SeriesMeta::RULE, true)) { unset($changes['recurrence_rule']); } $seriesId = $this->split($seriesId, $eventId); if ($seriesId === 0) { return new \WP_Error('pcwp_split_failed', __('The series could not be split.', 'proeventswp'), ['status' => 500]); } } $this->apply($seriesId, $changes, false); if (isset($changes['recurrence_rule'])) { update_post_meta($seriesId, SeriesMeta::RULE, SeriesMeta::sanitizeRule($changes['recurrence_rule'])); update_post_meta($seriesId, '_pcwp_recurrence_engine', 'rrule-3'); } foreach (['recurrence_exclusions' => SeriesMeta::EXCLUSIONS, 'recurrence_additions' => SeriesMeta::ADDITIONS] as $field => $key) { if (isset($changes[$field])) { update_post_meta($seriesId, $key, SeriesMeta::sanitizeDates($changes[$field])); } } $this->materializer->materialize($seriesId, 0); return new \WP_REST_Response(['event_id' => $eventId, 'series_id' => $seriesId, 'scope' => $scope]); } /** @param array $changes */ private function apply(int $postId, array $changes, bool $occurrence): void { $post = ['ID' => $postId]; $overrides = EventMeta::sanitizeOverrides(get_post_meta($postId, '_pcwp_series_overrides', true)); foreach (['title' => 'post_title', 'content' => 'post_content', 'excerpt' => 'post_excerpt', 'password' => 'post_password'] as $field => $postField) { if (! isset($changes[$field]) || ! is_scalar($changes[$field])) { continue; } $post[$postField] = $field === 'content' ? wp_kses_post((string) $changes[$field]) : sanitize_text_field((string) $changes[$field]); if ($occurrence) { $overrides[] = $field; } } if (count($post) > 1) { wp_update_post($post); } $definitions = EventMeta::definitions(); foreach ($changes as $field => $value) { $metaKey = '_pcwp_' . sanitize_key((string) $field); if (! isset($definitions[$metaKey]) || in_array($metaKey, ['_pcwp_series_id', '_pcwp_series_sequence', '_pcwp_series_overrides', '_pcwp_recurrence_id', '_pcwp_schedule'], true)) { continue; } $callback = $definitions[$metaKey]['sanitize_callback']; $normalized = call_user_func($callback, $value); update_post_meta($postId, $metaKey, $metaKey === \ProCalendarWP\Maps\Location::KEY ? wp_slash($normalized) : $normalized); if ($occurrence) { $overrides[] = in_array($field, ['start', 'end', 'timezone'], true) ? 'schedule' : sanitize_key((string) $field); } } if ($occurrence) { update_post_meta($postId, '_pcwp_series_overrides', EventMeta::sanitizeOverrides($overrides)); } } private function split(int $seriesId, int $eventId): int { $series = get_post($seriesId); $event = get_post($eventId); $identity = (string) get_post_meta($eventId, '_pcwp_recurrence_id', true); $splitValue = $identity !== '' ? (new DateTimeImmutable($identity))->format('Y-m-d\TH:i:s') : (string) get_post_meta($eventId, '_pcwp_start', true); if (! $series instanceof \WP_Post || ! $event instanceof \WP_Post || $splitValue === '') { return 0; } $newId = wp_insert_post([ 'post_type' => 'pcwp_series', 'post_status' => $series->post_status, 'post_title' => $series->post_title, 'post_content' => $series->post_content, 'post_excerpt' => $series->post_excerpt, 'post_author' => (int) $series->post_author, 'post_password' => $series->post_password, ], true); if (is_wp_error($newId)) { return 0; } $newId = (int) $newId; foreach (array_merge(array_keys(EventMeta::definitions()), [SeriesMeta::RULE, SeriesMeta::EXCLUSIONS, SeriesMeta::ADDITIONS, '_pcwp_venue_ids', '_pcwp_organizer_ids']) as $key) { if (in_array($key, ['_pcwp_series_id', '_pcwp_series_sequence', '_pcwp_series_overrides'], true)) { continue; } $value = get_post_meta($seriesId, $key, true); if ($value !== '') { update_post_meta($newId, $key, $key === \ProCalendarWP\Maps\Location::KEY ? wp_slash($value) : $value); } } update_post_meta($newId, '_pcwp_start', $splitValue); $modern = get_post_meta($seriesId, '_pcwp_recurrence_engine', true) === 'rrule-3'; try { $zone = new DateTimeZone(EventMeta::sanitizeTimezone(get_post_meta($seriesId, '_pcwp_timezone', true))); $seriesTime = \ProCalendarWP\Events\EventTime::fromStored($seriesId, (string) get_post_meta($seriesId, '_pcwp_start', true), (string) get_post_meta($seriesId, '_pcwp_end', true), $zone->getName(), $modern); $seriesStart = $seriesTime->start; $seriesEnd = $seriesTime->end; $splitDate = $identity !== '' ? (new DateTimeImmutable($identity))->setTimezone($zone) : new DateTimeImmutable($splitValue, $zone); } catch (\Exception) { wp_delete_post($newId, true); return 0; } $newEnd = get_post_meta($seriesId, '_pcwp_all_day', true) ? $splitDate->add($seriesStart->diff($seriesEnd)) : $splitDate->add(new \DateInterval('PT' . max(1, $seriesEnd->getTimestamp() - $seriesStart->getTimestamp()) . 'S')); update_post_meta($newId, '_pcwp_end', $newEnd->format('Y-m-d\TH:i:s')); update_post_meta($newId, '_pcwp_recurrence_time', ['start_local' => $splitDate->format('Y-m-d\TH:i:s'), 'end_local' => $newEnd->format('Y-m-d\TH:i:s'), 'timezone' => $zone->getName(), 'start_utc' => $splitDate->setTimezone(new DateTimeZone('UTC'))->format(DATE_ATOM), 'end_utc' => $newEnd->setTimezone(new DateTimeZone('UTC'))->format(DATE_ATOM), 'rfc' => $modern]); $selectedTime = \ProCalendarWP\Events\EventTime::fromStored($eventId, (string) get_post_meta($eventId, '_pcwp_start', true), (string) get_post_meta($eventId, '_pcwp_end', true), $zone->getName()); if ($selectedTime->start->getTimestamp() !== $splitDate->getTimestamp() || $selectedTime->end->getTimestamp() !== $newEnd->getTimestamp()) { update_post_meta($eventId, '_pcwp_series_overrides', EventMeta::sanitizeOverrides(array_merge((array) get_post_meta($eventId, '_pcwp_series_overrides', true), ['schedule']))); } set_post_thumbnail($newId, get_post_thumbnail_id($seriesId)); foreach (['pcwp_event_category', 'pcwp_event_tag'] as $taxonomy) { wp_set_object_terms($newId, wp_get_object_terms($seriesId, $taxonomy, ['fields' => 'ids']), $taxonomy); } $until = $splitDate->modify('-1 second')->setTimezone(new DateTimeZone('UTC'))->format('Ymd\THis\Z'); $rule = (string) get_post_meta($seriesId, SeriesMeta::RULE, true); $parsed = (new Rfc5545Engine())->parse($rule); $splitEngine = $modern ? new Rfc5545Engine() : new LegacyRfc5545Engine(); $primary = iterator_to_array($splitEngine->expand($rule, $seriesStart, $seriesStart->diff($seriesEnd), $seriesStart, $splitDate)); $matchesRule = (bool) array_filter($primary, static fn ($time): bool => $time->start->getTimestamp() === $splitDate->getTimestamp()); if (! $matchesRule) { // An added date does not reset the original weekday/interval anchor or COUNT. update_post_meta($newId, '_pcwp_start', $seriesStart->format('Y-m-d\TH:i:s')); update_post_meta($newId, '_pcwp_end', $seriesEnd->format('Y-m-d\TH:i:s')); update_post_meta($newId, '_pcwp_recurrence_time', ['start_local' => $seriesStart->format('Y-m-d\TH:i:s'), 'end_local' => $seriesEnd->format('Y-m-d\TH:i:s'), 'timezone' => $zone->getName(), 'start_utc' => $seriesStart->setTimezone(new DateTimeZone('UTC'))->format(DATE_ATOM), 'end_utc' => $seriesEnd->setTimezone(new DateTimeZone('UTC'))->format(DATE_ATOM), 'rfc' => $modern]); } elseif (isset($parsed['COUNT'])) { $prior = iterator_to_array((new Rfc5545Engine())->expand($rule, $seriesStart, $seriesStart->diff($seriesEnd), $seriesStart, $splitDate->modify('-1 second'))); $remaining = max(1, (int) $parsed['COUNT'] - count($prior)); update_post_meta($newId, SeriesMeta::RULE, (string) preg_replace('/COUNT=\d+/', 'COUNT=' . $remaining, $rule)); } update_post_meta($newId, '_pcwp_recurrence_engine', $modern ? 'rrule-3' : 'legacy'); update_post_meta($newId, '_pcwp_recurrence_from', ($matchesRule ? $splitDate->setTime(0, 0) : $splitDate)->format(DATE_ATOM)); $dateEngine = new Rfc5545Engine(); foreach ([SeriesMeta::ADDITIONS, SeriesMeta::EXCLUSIONS] as $key) { $before = []; $following = []; foreach (SeriesMeta::sanitizeDates(get_post_meta($seriesId, $key, true)) as $date) { if ($dateEngine->exceptionDate($date, $seriesStart) < $splitDate) { $before[] = $date; } else { $following[] = $date; } } update_post_meta($seriesId, $key, $before); update_post_meta($newId, $key, $following); } $parts = array_values(array_filter(explode(';', preg_replace('/^RRULE:/i', '', $rule) ?? $rule), static fn (string $part): bool => ! str_starts_with($part, 'UNTIL=') && ! str_starts_with($part, 'COUNT='))); update_post_meta($seriesId, SeriesMeta::RULE, implode(';', $parts) . ';UNTIL=' . $until); $future = get_posts(['post_type' => 'pcwp_event', 'post_status' => ['publish', 'draft', 'pending', 'future', 'private', 'trash'], 'posts_per_page' => -1, 'fields' => 'ids', 'meta_key' => '_pcwp_series_id', 'meta_value' => (string) $seriesId]); foreach ($future as $id) { $identity = (string) get_post_meta((int) $id, '_pcwp_recurrence_id', true); $original = new DateTimeImmutable($identity !== '' ? $identity : (string) get_post_meta((int) $id, '_pcwp_start', true), $zone); if ($original >= $splitDate) { update_post_meta((int) $id, '_pcwp_series_id', $newId); } } return $newId; } }