'pcwp_series', 'post_status' => ['publish', 'draft', 'pending', 'future', 'private'], 'posts_per_page' => -1, 'fields' => 'ids', ]); foreach ($seriesIds as $seriesId) { $windowEnd = (string) get_post_meta((int) $seriesId, SeriesMeta::WINDOW_END, true); $windowTimestamp = strtotime($windowEnd); if ($windowTimestamp === false || $windowTimestamp < $threshold) { $args = [(int) $seriesId, 0]; if (! wp_next_scheduled('pcwp_materialize_series', $args)) { wp_schedule_single_event(time() + 1, 'pcwp_materialize_series', $args); } } } } public function queue(int $seriesId, \WP_Post $post): void { if ($this->updating || wp_is_post_revision($seriesId) || $post->post_status === 'trash') { return; } if (! wp_next_scheduled('pcwp_materialize_series', [$seriesId, 0])) { wp_schedule_single_event(time() + 1, 'pcwp_materialize_series', [$seriesId, 0]); } } /** @param int|list $metaId Deletion hooks pass a list of metadata IDs. */ public function metaChanged(int|array $metaId, int $postId, string $key, mixed $value): void { unset($metaId, $value); if ($this->updating || in_array($key, ['_pcwp_recurrence_error', '_pcwp_recurrence_review', '_pcwp_recurrence_engine', '_pcwp_next_start_utc', SeriesMeta::WINDOW_END], true) || ! str_starts_with($key, '_pcwp_') || get_post_type($postId) !== 'pcwp_series') { return; } $post = get_post($postId); if ($post instanceof \WP_Post) { $this->queue($postId, $post); } } public function materialize(int $seriesId, int $offset = 0, string $generation = ''): void { global $wpdb; $lock = 'pcwp_series_' . md5($wpdb->prefix . $seriesId); if ((string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s,0)', $lock)) !== '1') { wp_schedule_single_event(time() + 10, 'pcwp_materialize_series', [$seriesId, $offset, $generation]); return; } try { $this->materializeBatch($seriesId, $offset, $generation); } finally { $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); } } private function materializeBatch(int $seriesId, int $offset, string $generation): void { $series = get_post($seriesId); if (! $series instanceof \WP_Post || $series->post_type !== 'pcwp_series' || $series->post_status === 'trash') { return; } try { $existing = $this->existing($seriesId); $legacy = get_post_meta($seriesId, '_pcwp_recurrence_engine', true) !== 'rrule-3' && $existing !== []; if ($legacy && $offset === 0) { [$modern] = $this->schedule($seriesId, $this->engine); [$old] = $this->schedule($seriesId, new LegacyRfc5545Engine()); $keys = static fn (array $times): array => array_map(static fn (EventTime $time): string => $time->start->format('Y-m-d H:i:sP') . ' / ' . $time->end->format('Y-m-d H:i:sP'), $times); if ($keys($modern) === $keys($old)) { $legacy = false; } else { update_post_meta($seriesId, '_pcwp_recurrence_review', ['added' => array_values(array_diff($keys($modern), $keys($old))), 'removed' => array_values(array_diff($keys($old), $keys($modern)))]); } } if (! $legacy) { update_post_meta($seriesId, '_pcwp_recurrence_engine', 'rrule-3'); delete_post_meta($seriesId, '_pcwp_recurrence_review'); } [$schedule, $windowStart, $windowEnd] = $this->schedule($seriesId, $legacy ? new LegacyRfc5545Engine() : $this->engine); } catch (\InvalidArgumentException $exception) { update_post_meta($seriesId, '_pcwp_recurrence_error', sanitize_text_field($exception->getMessage())); return; } $slice = array_slice($schedule, max(0, $offset), self::BATCH_SIZE); $fingerprint = hash('sha256', (string) wp_json_encode([ $series->post_title, $series->post_content, $series->post_excerpt, array_map(static fn (EventTime $time): string => $time->start->format(DATE_ATOM) . '/' . $time->end->format(DATE_ATOM), $schedule), ])); if ($offset > 0 && $generation !== $fingerprint) { $this->queue($seriesId, $series); return; } $byDay = []; foreach ($existing as $key => $id) { $byDay[substr($key, 0, 10)][] = $id; } $scheduleDays = array_count_values(array_map(static fn (EventTime $time): string => $time->start->format('Y-m-d'), $schedule)); $this->updating = true; do_action('pcwp_series_propagation_start', $seriesId); try { foreach ($slice as $sequence => $time) { $actualSequence = $offset + $sequence; $key = $time->start->format('Y-m-d H:i:sP'); $day = $time->start->format('Y-m-d'); $eventId = $existing[$key] ?? ((count($byDay[$day] ?? []) === 1 && ($scheduleDays[$day] ?? 0) === 1) ? $byDay[$day][0] : 0); $this->writeOccurrence($series, $time, $actualSequence, $eventId); } if ($offset + count($slice) >= count($schedule)) { $valid = []; foreach ($schedule as $time) { $valid[$time->start->format('Y-m-d H:i:sP')] = true; } foreach ($this->existing($seriesId) as $key => $existingId) { $date = new DateTimeImmutable($key); $overrides = EventMeta::sanitizeOverrides(get_post_meta($existingId, '_pcwp_series_overrides', true)); if ($date >= new DateTimeImmutable('now', $date->getTimezone()) && $date >= $windowStart && $date <= $windowEnd && ! isset($valid[$key]) && ! in_array('schedule', $overrides, true)) { if (class_exists(\ProCalendarWP\Rsvp\RsvpService::class) && \ProCalendarWP\Rsvp\RsvpService::activeCount($existingId) > 0) { update_post_meta($existingId, '_pcwp_status', 'canceled'); update_post_meta($existingId, '_pcwp_recurrence_attention', true); } else { wp_trash_post($existingId); } } } update_post_meta($seriesId, SeriesMeta::WINDOW_END, $windowEnd->format(DATE_ATOM)); delete_post_meta($seriesId, '_pcwp_recurrence_error'); } } finally { do_action('pcwp_series_propagation_end', $seriesId); $this->updating = false; } $next = $offset + count($slice); if ($next < count($schedule)) { wp_schedule_single_event(time() + 1, 'pcwp_materialize_series', [$seriesId, $next, $fingerprint]); } } /** @return array{0:list,1:DateTimeImmutable,2:DateTimeImmutable} */ private function schedule(int $seriesId, ?RecurrenceEngineInterface $engine = null): array { $timezone = EventMeta::sanitizeTimezone(get_post_meta($seriesId, '_pcwp_timezone', true)); $zone = new DateTimeZone($timezone); $startValue = (string) get_post_meta($seriesId, '_pcwp_start', true); $endValue = (string) get_post_meta($seriesId, '_pcwp_end', true); if ($startValue === '' || $endValue === '') { throw new \InvalidArgumentException('The series needs a start and end date.'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $start = new DateTimeImmutable($startValue, $zone); $end = new DateTimeImmutable($endValue, $zone); if (($engine ?? $this->engine) instanceof Rfc5545Engine) { $stored = EventTime::fromStored($seriesId, $startValue, $endValue, $timezone, true); $start = $stored->start; $end = $stored->end; } if ($end < $start || ($end == $start && ! get_post_meta($seriesId, '_pcwp_all_day', true))) { throw new \InvalidArgumentException('The series end must be after its start.'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $duration = get_post_meta($seriesId, '_pcwp_all_day', true) ? $start->diff($end) : new DateInterval('PT' . max(1, $end->getTimestamp() - $start->getTimestamp()) . 'S'); $today = new DateTimeImmutable('today', $zone); $from = $start > $today->sub(new DateInterval('P1Y')) ? $start : $today->sub(new DateInterval('P1Y')); $cutoff = (string) get_post_meta($seriesId, '_pcwp_recurrence_from', true); if ($cutoff !== '') { $boundary = new DateTimeImmutable($cutoff); $from = $boundary > $from ? $boundary : $from; } $until = $today->add(new DateInterval('P2Y')); $events = iterator_to_array(($engine ?? $this->engine)->expand( (string) get_post_meta($seriesId, SeriesMeta::RULE, true), $start, $duration, $from, $until, SeriesMeta::sanitizeDates(get_post_meta($seriesId, SeriesMeta::EXCLUSIONS, true)), SeriesMeta::sanitizeDates(get_post_meta($seriesId, SeriesMeta::ADDITIONS, true)), ), false); return [$events, $from, $until]; } /** @return array */ private function existing(int $seriesId): array { $ids = 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, 'orderby' => 'ID', 'order' => 'ASC', ]); $result = []; foreach ($ids as $id) { $start = (string) get_post_meta((int) $id, '_pcwp_recurrence_id', true); if ($start === '') { $start = (string) get_post_meta((int) $id, '_pcwp_start', true); } $timezone = EventMeta::sanitizeTimezone(get_post_meta((int) $id, '_pcwp_timezone', true)); try { $result[(new DateTimeImmutable($start, new DateTimeZone($timezone)))->format('Y-m-d H:i:sP')] = (int) $id; } catch (\Exception) { continue; } } return $result; } private function writeOccurrence(\WP_Post $series, EventTime $time, int $sequence, int $eventId): void { if ($eventId > 0 && $time->start < new DateTimeImmutable('now', $time->start->getTimezone())) { $overrides = EventMeta::sanitizeOverrides(get_post_meta($eventId, '_pcwp_series_overrides', true)); wp_update_post(['ID' => $eventId, 'post_status' => $series->post_status === 'publish' ? 'publish' : 'draft', 'post_password' => in_array('password', $overrides, true) ? (string) get_post_field('post_password', $eventId) : $series->post_password]); return; } $overrides = $eventId > 0 ? EventMeta::sanitizeOverrides(get_post_meta($eventId, '_pcwp_series_overrides', true)) : []; $post = [ 'ID' => $eventId, 'post_type' => 'pcwp_event', 'post_status' => $series->post_status === 'publish' ? 'publish' : 'draft', 'post_title' => in_array('title', $overrides, true) ? get_the_title($eventId) : $series->post_title, 'post_content' => in_array('content', $overrides, true) ? (string) get_post_field('post_content', $eventId) : $series->post_content, 'post_excerpt' => in_array('excerpt', $overrides, true) ? (string) get_post_field('post_excerpt', $eventId) : $series->post_excerpt, 'post_author' => (int) $series->post_author, 'post_password' => in_array('password', $overrides, true) ? (string) get_post_field('post_password', $eventId) : $series->post_password, ]; if ($eventId === 0) { unset($post['ID']); $post['post_name'] = sanitize_title($series->post_name . '-' . $time->start->format('Y-m-d-His')); } $saved = wp_insert_post($post, true); if (is_wp_error($saved)) { return; } $eventId = (int) $saved; update_post_meta($eventId, '_pcwp_series_id', $series->ID); update_post_meta($eventId, '_pcwp_series_sequence', $sequence); update_post_meta($eventId, '_pcwp_recurrence_id', $time->start->format('Y-m-d H:i:sP')); if (! in_array('schedule', $overrides, true)) { update_post_meta($eventId, '_pcwp_start', $time->start->format('Y-m-d\TH:i:s')); update_post_meta($eventId, '_pcwp_end', $time->end->format('Y-m-d\TH:i:s')); update_post_meta($eventId, '_pcwp_timezone', $time->timezone); update_post_meta($eventId, '_pcwp_recurrence_time', ['start_local' => $time->start->format('Y-m-d\TH:i:s'), 'end_local' => $time->end->format('Y-m-d\TH:i:s'), 'timezone' => $time->timezone, 'start_utc' => $time->startUtc()->format(DATE_ATOM), 'end_utc' => $time->endUtc()->format(DATE_ATOM), 'rfc' => get_post_meta($series->ID, '_pcwp_recurrence_engine', true) === 'rrule-3']); } foreach (EventMeta::definitions() as $key => $definition) { unset($definition); $field = ltrim(str_replace('_pcwp_', '', $key), '_'); if ( in_array($key, ['_pcwp_schedule', '_pcwp_start', '_pcwp_end', '_pcwp_timezone', '_pcwp_series_id', '_pcwp_series_sequence', '_pcwp_series_overrides', '_pcwp_recurrence_id'], true) || in_array($field, $overrides, true) ) { continue; } $value = get_post_meta($series->ID, $key, true); if ($value === '') { delete_post_meta($eventId, $key); } else { update_post_meta($eventId, $key, $key === \ProCalendarWP\Maps\Location::KEY ? wp_slash($value) : $value); } } if (! in_array('image', $overrides, true)) { set_post_thumbnail($eventId, get_post_thumbnail_id($series->ID)); } foreach (['pcwp_event_category', 'pcwp_event_tag'] as $taxonomy) { if (! in_array($taxonomy, $overrides, true)) { wp_set_object_terms($eventId, wp_get_object_terms($series->ID, $taxonomy, ['fields' => 'ids']), $taxonomy); } } $this->copyEntities($series->ID, $eventId, $overrides); $this->linkTranslation($series->ID, $eventId, $sequence); } /** @param list $overrides */ private function copyEntities(int $seriesId, int $eventId, array $overrides): void { global $wpdb; $table = $wpdb->prefix . 'pcwp_event_entities'; foreach (['venue' => '_pcwp_venue_ids', 'organizer' => '_pcwp_organizer_ids'] as $type => $metaKey) { if (in_array($type . 's', $overrides, true)) { continue; } $wpdb->delete($table, ['event_id' => $eventId, 'entity_type' => $type], ['%d', '%s']); foreach (SeriesMeta::sanitizeIds(get_post_meta($seriesId, $metaKey, true)) as $order => $entityId) { $wpdb->insert($table, [ 'event_id' => $eventId, 'entity_id' => $entityId, 'entity_type' => $type, 'sort_order' => $order, ], ['%d', '%d', '%s', '%d']); } } } private function linkTranslation(int $seriesId, int $eventId, int $sequence): void { if (! has_filter('wpml_element_language_details')) { return; } $details = apply_filters('wpml_element_language_details', null, [ 'element_id' => $seriesId, 'element_type' => 'post_pcwp_series', ]); if (! is_object($details) || ! isset($details->language_code, $details->trid)) { return; } $eventTrid = null; $translations = apply_filters('wpml_get_element_translations', [], $details->trid, 'post_pcwp_series'); foreach (is_array($translations) ? $translations : [] as $translation) { if (! is_object($translation) || empty($translation->element_id) || (int) $translation->element_id === $seriesId) { continue; } $matches = get_posts([ 'post_type' => 'pcwp_event', 'post_status' => 'any', 'posts_per_page' => 1, 'fields' => 'ids', 'meta_query' => [ ['key' => '_pcwp_series_id', 'value' => (string) (int) $translation->element_id], ['key' => '_pcwp_series_sequence', 'value' => (string) $sequence], ], ]); if ($matches !== []) { $other = apply_filters('wpml_element_language_details', null, ['element_id' => (int) $matches[0], 'element_type' => 'post_pcwp_event']); if (is_object($other) && isset($other->trid)) { $eventTrid = (int) $other->trid; break; } } } do_action('wpml_set_element_language_details', [ 'element_id' => $eventId, 'element_type' => 'post_pcwp_event', 'trid' => $eventTrid, 'language_code' => (string) $details->language_code, 'source_language_code' => isset($details->source_language_code) ? (string) $details->source_language_code : null, ]); } }