seriesPropagation = true; } public function endSeriesPropagation(): void { $this->seriesPropagation = false; } /** @param int|list $metaId Deleted metadata supplies a list of IDs. */ public function syncMeta(int|array $metaId, int $postId, string $metaKey, mixed $metaValue): void { unset($metaId, $metaValue); if ($this->syncing || ScheduleStorage::$deriving || $metaKey === '_pcwp_series_overrides') { return; } if ($metaKey === '_thumbnail_id') { if (! $this->seriesPropagation) { $this->recordNamedOverride($postId, 'image'); } return; } if ($metaKey === '_pcwp_recurrence_time') { $post = get_post($postId); if ($post instanceof \WP_Post && $post->post_type === 'pcwp_event') { $this->sync($postId, $post); } return; } if (! array_key_exists($metaKey, EventMeta::definitions())) { return; } $post = get_post($postId); if ($post instanceof \WP_Post && $post->post_type === 'pcwp_event') { if (! $this->seriesPropagation) { $this->recordMetaOverride($postId, $metaKey); } $this->sync($postId, $post); } } /** @param list $oldTermTaxonomyIds */ public function termsChanged(int $objectId, mixed $terms, mixed $termTaxonomyIds, string $taxonomy, bool $append, array $oldTermTaxonomyIds): void { unset($terms, $termTaxonomyIds, $append, $oldTermTaxonomyIds); if (! $this->seriesPropagation && in_array($taxonomy, ['pcwp_event_category', 'pcwp_event_tag'], true)) { $this->recordNamedOverride($objectId, $taxonomy); } } public function sync(int $postId, \WP_Post $post): void { if ($this->syncing || ScheduleStorage::$restSaving || ScheduleStorage::$deriving || wp_is_post_revision($postId) || wp_is_post_autosave($postId)) { return; } if (! current_user_can('edit_post', $postId) && ! wp_doing_cron()) { return; } global $wpdb; $lock = 'pcwp_schedule_' . md5($wpdb->prefix . $postId); if ((string) $wpdb->get_var($wpdb->prepare('SELECT GET_LOCK(%s,10)', $lock)) !== '1') { return; } $this->syncing = true; try { if (! $this->seriesPropagation) { $this->recordEditorialOverrides($postId, $post); } if (! $this->saveSubmittedMeta($postId)) { return; } $this->syncEntities($postId); $this->syncOccurrence($postId, $post); WpEventRepository::clearEventCache($postId); do_action('pcwp_occurrence_synced', $postId); } catch (\RuntimeException $error) { set_transient('pcwp_schedule_error_' . get_current_user_id(), $error->getMessage(), 120); } finally { $this->syncing = false; $wpdb->get_var($wpdb->prepare('SELECT RELEASE_LOCK(%s)', $lock)); } } public function delete(int $postId): void { if (get_post_type($postId) !== 'pcwp_event') { return; } global $wpdb; $wpdb->delete($wpdb->prefix . 'pcwp_occurrences', ['event_id' => $postId], ['%d']); $wpdb->delete($wpdb->prefix . 'pcwp_daily_intervals', ['event_id' => $postId], ['%d']); $wpdb->delete($wpdb->prefix . 'pcwp_event_entities', ['event_id' => $postId], ['%d']); } /** * @param array $actions Row actions. * @return array */ public function duplicateAction(array $actions, \WP_Post $post): array { if ($post->post_type === 'pcwp_event' && current_user_can('edit_post', $post->ID) && \ProCalendarWP\Infrastructure\Capabilities::canCreatePosts()) { $url = wp_nonce_url(admin_url('admin.php?action=pcwp_duplicate_event&post=' . $post->ID), 'pcwp_duplicate_' . $post->ID); $actions['pcwp_duplicate'] = '' . esc_html__('Duplicate event', 'proeventswp') . ''; } return $actions; } public function duplicate(): void { $sourceId = isset($_GET['post']) ? absint($_GET['post']) : 0; check_admin_referer('pcwp_duplicate_' . $sourceId); $source = get_post($sourceId); if (! $source instanceof \WP_Post || $source->post_type !== 'pcwp_event' || ! current_user_can('edit_post', $sourceId) || ! \ProCalendarWP\Infrastructure\Capabilities::canCreatePosts()) { wp_die(esc_html__('You cannot duplicate this event.', 'proeventswp')); } $newId = wp_insert_post([ 'post_type' => 'pcwp_event', 'post_status' => 'draft', /* translators: %s is the original event title. */ 'post_title' => sprintf(__('%s (copy)', 'proeventswp'), $source->post_title), 'post_content' => $source->post_content, 'post_excerpt' => $source->post_excerpt, 'post_author' => get_current_user_id(), ], true); if (is_wp_error($newId)) { wp_die(esc_html($newId->get_error_message())); } foreach (array_keys(EventMeta::definitions()) as $key) { if (in_array($key, ['_pcwp_schedule', '_pcwp_start', '_pcwp_end', '_pcwp_status', '_pcwp_series_id', '_pcwp_series_sequence', '_pcwp_series_overrides'], true)) { continue; } $value = get_post_meta($sourceId, $key, true); if ($value !== '') { update_post_meta($newId, $key, $key === \ProCalendarWP\Maps\Location::KEY ? wp_slash($value) : $value); } } update_post_meta($newId, '_pcwp_status', 'scheduled'); set_post_thumbnail($newId, get_post_thumbnail_id($sourceId)); wp_set_object_terms($newId, wp_get_object_terms($sourceId, 'pcwp_event_category', ['fields' => 'ids']), 'pcwp_event_category'); wp_set_object_terms($newId, wp_get_object_terms($sourceId, 'pcwp_event_tag', ['fields' => 'ids']), 'pcwp_event_tag'); $this->copyEntities($sourceId, (int) $newId); wp_safe_redirect(admin_url('post.php?action=edit&post=' . $newId)); exit; } private function saveSubmittedMeta(int $postId): bool { if (! isset($_POST['pcwp_event_nonce']) || ! wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['pcwp_event_nonce'])), 'pcwp_save_event')) { return true; } try { $input = isset($_POST['pcwp_schedule']) ? json_decode(wp_unslash((string) $_POST['pcwp_schedule']), true, 512, JSON_THROW_ON_ERROR) : get_post_meta($postId, '_pcwp_schedule', true); $timezone = isset($_POST['pcwp_timezone']) ? EventMeta::sanitizeTimezone(sanitize_text_field(wp_unslash($_POST['pcwp_timezone']))) : EventMeta::sanitizeTimezone(get_post_meta($postId, '_pcwp_timezone', true)); $schedule = DailySchedule::validate($input, $timezone, (int) get_post_meta($postId, '_pcwp_series_id', true) > 0); } catch (\InvalidArgumentException | \JsonException $error) { set_transient('pcwp_schedule_error_' . get_current_user_id(), $error->getMessage(), 120); return false; } ScheduleStorage::$deriving = true; try { update_post_meta($postId, '_pcwp_schedule', $schedule); foreach (EventMeta::definitions() as $key => $definition) { if ($key === '_pcwp_schedule') { continue; } $formKey = ltrim($key, '_'); if (($definition['type'] ?? '') === 'boolean') { update_post_meta($postId, $key, isset($_POST[$formKey]) ? '1' : '0'); continue; } if (! isset($_POST[$formKey])) { continue; } $callback = $definition['sanitize_callback']; update_post_meta($postId, $key, call_user_func($callback, wp_unslash($_POST[$formKey]))); } } finally { ScheduleStorage::$deriving = false; } return true; } private function syncOccurrence(int $postId, \WP_Post $post): void { global $wpdb; $timezone = EventMeta::sanitizeTimezone(get_post_meta($postId, '_pcwp_timezone', true)); try { $schedule = DailySchedule::validate(get_post_meta($postId, '_pcwp_schedule', true), $timezone, (int) get_post_meta($postId, '_pcwp_series_id', true) > 0); if ($schedule['type'] === 'daily') { $slots = DailySchedule::intervals($schedule, $timezone); ScheduleStorage::$deriving = true; try { update_post_meta($postId, '_pcwp_start', $slots[0]['time']->start->format('Y-m-d\TH:i:s')); update_post_meta($postId, '_pcwp_end', $slots[count($slots) - 1]['time']->end->format('Y-m-d\TH:i:s')); update_post_meta($postId, '_pcwp_all_day', false); } finally { ScheduleStorage::$deriving = false; } } $eventTime = EventTime::fromStored( $postId, (string) get_post_meta($postId, '_pcwp_start', true), (string) get_post_meta($postId, '_pcwp_end', true), $timezone, ); $start = $eventTime->start; $end = $eventTime->end; } catch (\InvalidArgumentException) { $wpdb->delete($wpdb->prefix . 'pcwp_occurrences', ['event_id' => $postId], ['%d']); $wpdb->delete($wpdb->prefix . 'pcwp_daily_intervals', ['event_id' => $postId], ['%d']); return; } $table = $wpdb->prefix . 'pcwp_occurrences'; $fingerprint = hash('sha256', $postId . '|base'); $now = current_time('mysql', true); $revision = $schedule['type'] === 'daily' ? ScheduleStorage::stage($postId, $schedule, $timezone) : ''; $location = \ProCalendarWP\Maps\Location::resolve($postId); $saved = $wpdb->query($wpdb->prepare( "INSERT INTO {$table} (event_id,series_id,start_utc,end_utc,start_local,end_local,timezone,all_day,event_status,event_mode,featured,cost_decimal,latitude,longitude,fingerprint,created_at,updated_at,schedule_type,schedule_revision) VALUES (%d,NULLIF(%d,0),%s,%s,%s,%s,%s,%d,%s,%s,%d,NULLIF(%s,''),NULLIF(%s,''),NULLIF(%s,''),%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE series_id=VALUES(series_id),start_utc=VALUES(start_utc),end_utc=VALUES(end_utc),start_local=VALUES(start_local),end_local=VALUES(end_local),timezone=VALUES(timezone),all_day=VALUES(all_day),event_status=VALUES(event_status),event_mode=VALUES(event_mode),featured=VALUES(featured),cost_decimal=VALUES(cost_decimal),latitude=VALUES(latitude),longitude=VALUES(longitude),updated_at=VALUES(updated_at),schedule_type=VALUES(schedule_type),schedule_revision=VALUES(schedule_revision)", $postId, absint(get_post_meta($postId, '_pcwp_series_id', true)), $eventTime->startUtc()->format('Y-m-d H:i:s'), $eventTime->endUtc()->format('Y-m-d H:i:s'), $start->format('Y-m-d H:i:s'), $end->format('Y-m-d H:i:s'), $timezone, (int) (bool) get_post_meta($postId, '_pcwp_all_day', true), EventMeta::sanitizeStatus(get_post_meta($postId, '_pcwp_status', true)), EventMeta::sanitizeMode(get_post_meta($postId, '_pcwp_mode', true)), (int) (bool) get_post_meta($postId, '_pcwp_featured', true), is_numeric($cost = get_post_meta($postId, '_pcwp_cost', true)) ? (string) $cost : '', $location['latitude'] === null ? '' : (string) $location['latitude'], $location['longitude'] === null ? '' : (string) $location['longitude'], $fingerprint, $now, $now, $schedule['type'], $revision, )); if ($saved === false) { $wpdb->delete($wpdb->prefix . 'pcwp_daily_intervals', ['event_id' => $postId, 'revision' => $revision]); throw new \RuntimeException('Could not update the event schedule projection.'); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are data; REST encodes them and admin callers escape output. } $wpdb->query($wpdb->prepare("DELETE FROM {$wpdb->prefix}pcwp_daily_intervals WHERE event_id=%d AND revision<>%s", $postId, $revision)); if ($post->post_status === 'trash') { $wpdb->delete($table, ['event_id' => $postId], ['%d']); $wpdb->delete($wpdb->prefix . 'pcwp_daily_intervals', ['event_id' => $postId], ['%d']); } } private function syncEntities(int $postId): void { if (! isset($_POST['pcwp_event_nonce']) || ! wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['pcwp_event_nonce'])), 'pcwp_save_event')) { return; } if (! $this->seriesPropagation && absint(get_post_meta($postId, '_pcwp_series_id', true)) > 0) { $overrides = EventMeta::sanitizeOverrides(get_post_meta($postId, '_pcwp_series_overrides', true)); $overrides[] = 'venues'; $overrides[] = 'organizers'; update_post_meta($postId, '_pcwp_series_overrides', EventMeta::sanitizeOverrides($overrides)); } $entities = ['venue' => [], 'organizer' => []]; foreach (['venue' => 'pcwp_venue_ids', 'organizer' => 'pcwp_organizer_ids'] as $type => $field) { $ids = isset($_POST[$field]) ? array_values(array_filter(array_map('absint', (array) wp_unslash($_POST[$field])))) : []; foreach ($ids as $order => $entityId) { $expected = $type === 'venue' ? 'pcwp_venue' : 'pcwp_organizer'; if (get_post_type($entityId) === $expected) { if (current_user_can('read_post', $entityId)) { $entities[$type][] = $entityId; } } } } (new EntityRelationships())->replace($postId, $entities); } private function copyEntities(int $sourceId, int $newId): void { global $wpdb; $table = $wpdb->prefix . 'pcwp_event_entities'; $wpdb->query($wpdb->prepare("INSERT IGNORE INTO {$table} (event_id,entity_id,entity_type,sort_order) SELECT %d,entity_id,entity_type,sort_order FROM {$table} WHERE event_id=%d", $newId, $sourceId)); } private function recordMetaOverride(int $postId, string $metaKey): void { $seriesId = absint(get_post_meta($postId, '_pcwp_series_id', true)); if ($seriesId === 0 || in_array($metaKey, ['_pcwp_series_id', '_pcwp_series_sequence', '_pcwp_series_overrides'], true)) { return; } $field = in_array($metaKey, ['_pcwp_start', '_pcwp_end', '_pcwp_timezone'], true) ? 'schedule' : substr($metaKey, strlen('_pcwp_')); $overrides = EventMeta::sanitizeOverrides(get_post_meta($postId, '_pcwp_series_overrides', true)); $different = $field === 'schedule' ? array_filter(['_pcwp_start', '_pcwp_end', '_pcwp_timezone'], static fn (string $key): bool => get_post_meta($postId, $key, true) !== get_post_meta($seriesId, $key, true)) !== [] : get_post_meta($postId, $metaKey, true) !== get_post_meta($seriesId, $metaKey, true); $overrides = array_values(array_diff($overrides, [$field])); if ($different) { $overrides[] = $field; } update_post_meta($postId, '_pcwp_series_overrides', EventMeta::sanitizeOverrides($overrides)); } private function recordEditorialOverrides(int $postId, \WP_Post $post): void { $seriesId = absint(get_post_meta($postId, '_pcwp_series_id', true)); $series = $seriesId > 0 ? get_post($seriesId) : null; if (! $series instanceof \WP_Post) { return; } $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 => $property) { $overrides = array_values(array_diff($overrides, [$field])); if ($post->{$property} !== $series->{$property}) { $overrides[] = $field; } } update_post_meta($postId, '_pcwp_series_overrides', EventMeta::sanitizeOverrides($overrides)); } private function recordNamedOverride(int $postId, string $field): void { if (get_post_type($postId) !== 'pcwp_event' || absint(get_post_meta($postId, '_pcwp_series_id', true)) === 0) { return; } $overrides = EventMeta::sanitizeOverrides(get_post_meta($postId, '_pcwp_series_overrides', true)); $overrides[] = $field; update_post_meta($postId, '_pcwp_series_overrides', EventMeta::sanitizeOverrides($overrides)); } }