From 94b51c3959fdf1175971239440570ca62220e250 Mon Sep 17 00:00:00 2001 From: Anthony Correa Date: Fri, 24 Apr 2026 14:38:38 -0500 Subject: [PATCH] Refine SportsPress webhook schedule templates --- includes/sp-webhooks.php | 1128 +++++++++++++++++++++++++++++++++--- tests/test-sp-webhooks.php | 325 +++++++++++ 2 files changed, 1370 insertions(+), 83 deletions(-) diff --git a/includes/sp-webhooks.php b/includes/sp-webhooks.php index 2755b10..e545ac3 100644 --- a/includes/sp-webhooks.php +++ b/includes/sp-webhooks.php @@ -40,6 +40,11 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { */ const SCHEDULE_SIGNATURE_META_KEY = '_tse_sp_webhook_schedule_signature'; + /** + * Meta key used to store the last known schedule snapshot. + */ + const SCHEDULE_SNAPSHOT_META_KEY = '_tse_sp_webhook_schedule_snapshot'; + /** * Singleton instance. * @@ -54,6 +59,13 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { */ private $booted = false; + /** + * Pending event ids to evaluate once the request settles. + * + * @var int[] + */ + private $pending_schedule_post_ids = array(); + /** * Get singleton instance. * @@ -82,14 +94,23 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { add_filter( 'tse_tonys_settings_tabs', array( $this, 'register_settings_tab' ) ); add_action( 'tse_tonys_settings_render_tab_' . self::TAB_WEBHOOKS, array( $this, 'render_settings_tab' ) ); add_action( 'post_updated', array( $this, 'handle_event_schedule_update' ), 10, 3 ); + add_action( 'set_object_terms', array( $this, 'handle_event_venue_update' ), 10, 6 ); + add_action( 'added_post_meta', array( $this, 'handle_event_team_meta_change' ), 10, 4 ); + add_action( 'updated_post_meta', array( $this, 'handle_event_team_meta_change' ), 10, 4 ); + add_action( 'deleted_post_meta', array( $this, 'handle_event_team_meta_change' ), 10, 4 ); + add_action( 'added_post_meta', array( $this, 'handle_event_status_meta_change' ), 10, 4 ); + add_action( 'updated_post_meta', array( $this, 'handle_event_status_meta_change' ), 10, 4 ); + add_action( 'deleted_post_meta', array( $this, 'handle_event_status_meta_change' ), 10, 4 ); add_action( 'added_post_meta', array( $this, 'handle_event_results_meta_change' ), 10, 4 ); add_action( 'updated_post_meta', array( $this, 'handle_event_results_meta_change' ), 10, 4 ); add_action( 'deleted_post_meta', array( $this, 'handle_event_results_meta_change' ), 10, 4 ); + add_action( 'shutdown', array( $this, 'process_pending_schedule_updates' ), 100 ); if ( is_admin() ) { add_action( 'admin_init', array( $this, 'register_settings' ) ); add_filter( 'option_page_capability_' . self::OPTION_GROUP, array( $this, 'settings_capability' ) ); add_action( 'wp_ajax_tse_sp_webhook_test', array( $this, 'handle_test_webhook_ajax' ) ); + add_action( 'admin_post_tse_sp_reset_schedule_snapshots', array( $this, 'handle_reset_schedule_snapshots_admin' ) ); } } @@ -203,11 +224,32 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { echo '

' . esc_html__( 'Create multiple outbound notifications for SportsPress events. Each webhook can listen for schedule changes, result updates, or both, then send the rendered message to the selected destination type.', 'tonys-sportspress-enhancements' ) . '

'; echo '

' . esc_html__( 'Google Chat incoming webhooks send JSON with a text field. GroupMe bot delivery sends bot_id and text to the GroupMe bots endpoint. Generic JSON sends a richer payload with message and context.', 'tonys-sportspress-enhancements' ) . '

'; + if ( isset( $_GET['tse_sp_snapshots_reset'] ) ) { + $reset_count = isset( $_GET['tse_sp_snapshots_reset_count'] ) ? absint( wp_unslash( $_GET['tse_sp_snapshots_reset_count'] ) ) : 0; + echo '

'; + echo esc_html( + sprintf( + /* translators: %d: number of SportsPress events reset. */ + __( 'Schedule snapshots reset and re-baselined for %d event(s).', 'tonys-sportspress-enhancements' ), + $reset_count + ) + ); + echo '

'; + } + + echo '
'; + echo '' . esc_html__( 'Schedule Snapshot Tools', 'tonys-sportspress-enhancements' ) . ''; + echo '

' . esc_html__( 'Reset stored schedule snapshots for existing events and immediately save a fresh baseline from the current event data.', 'tonys-sportspress-enhancements' ) . '

'; + echo '

'; + echo '' . esc_html__( 'Reset And Rebuild Snapshots', 'tonys-sportspress-enhancements' ) . ''; + echo '

'; + echo '
'; + echo '
'; echo '' . esc_html__( 'Message template variables', 'tonys-sportspress-enhancements' ) . ''; echo '

'; echo esc_html__( 'Use Jinja-style placeholders such as', 'tonys-sportspress-enhancements' ) . ' '; - echo '{{ event.title }}, {{ event.permalink }}, {{ trigger.key }}, {{ changes.previous.local_display }}, {{ changes.current.local_display }}, {{ results.summary }}, '; + echo '{{ event.title }}, {{ event.status }}, {{ event.venue.name }}, {{ event.field.short_name }}, {{ event.scheduled.local_display }}, {{ event.scheduled.timestamp|date("g:i A") }}, {{ changes.previous.local_display }}, {{ changes.current.local_display }}, {{ changes.previous.status }}, {{ changes.current.status }}, {{ results.summary }}, '; echo esc_html__( 'or serialize values safely for JSON with', 'tonys-sportspress-enhancements' ) . ' {{ event|tojson }} ' . esc_html__( 'and', 'tonys-sportspress-enhancements' ) . ' {{ event.title|tojson }}. '; echo esc_html__( 'The rendered template becomes the outgoing message text.', 'tonys-sportspress-enhancements' ); echo '

'; @@ -238,7 +280,7 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { } /** - * Send notifications when an event date/time changes. + * Send notifications when an event schedule changes. * * @param int $post_id Event post ID. * @param WP_Post $post_after Updated post object. @@ -254,28 +296,147 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { return; } - $previous = $this->event_schedule_from_post( $post_before ); - $current = $this->event_schedule_from_post( $post_after ); + $this->queue_schedule_update( $post_id ); + } - if ( $previous['local_iso'] === $current['local_iso'] && $previous['gmt_iso'] === $current['gmt_iso'] ) { + /** + * Send notifications when an event venue changes. + * + * @param int $object_id Object id. + * @param array|string $terms Submitted terms. + * @param array $tt_ids Current term taxonomy ids. + * @param string $taxonomy Taxonomy slug. + * @param bool $append Whether terms were appended. + * @param array $old_tt_ids Previous term taxonomy ids. + * @return void + */ + public function handle_event_venue_update( $object_id, $terms, $tt_ids, $taxonomy, $append, $old_tt_ids ) { + unset( $terms, $tt_ids, $append, $old_tt_ids ); + + if ( 'sp_venue' !== $taxonomy ) { return; } - $signature = md5( $current['gmt_iso'] . '|' . $current['local_iso'] ); - if ( $signature === (string) get_post_meta( $post_id, self::SCHEDULE_SIGNATURE_META_KEY, true ) ) { + $post = get_post( $object_id ); + if ( ! $this->should_handle_event_post( $object_id, $post ) ) { return; } - update_post_meta( $post_id, self::SCHEDULE_SIGNATURE_META_KEY, $signature ); + $this->queue_schedule_update( $object_id ); + } - $this->dispatch_trigger( - 'event_datetime_changed', - $post_id, - array( - 'previous' => $previous, - 'current' => $current, - ) - ); + /** + * Send notifications when an event team assignment changes. + * + * @param mixed $meta_id_or_ids Meta identifier. + * @param int $post_id Event post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @return void + */ + public function handle_event_team_meta_change( $meta_id_or_ids, $post_id, $meta_key, $meta_value ) { + unset( $meta_id_or_ids, $meta_value ); + + if ( 'sp_team' !== $meta_key ) { + return; + } + + $post = get_post( $post_id ); + if ( ! $this->should_handle_event_post( $post_id, $post ) ) { + return; + } + + $this->queue_schedule_update( $post_id ); + } + + /** + * Queue schedule evaluation when an event status changes. + * + * @param mixed $meta_id_or_ids Meta identifier. + * @param int $post_id Event post ID. + * @param string $meta_key Meta key. + * @param mixed $meta_value Meta value. + * @return void + */ + public function handle_event_status_meta_change( $meta_id_or_ids, $post_id, $meta_key, $meta_value ) { + unset( $meta_id_or_ids, $meta_value ); + + if ( 'sp_status' !== $meta_key ) { + return; + } + + $post = get_post( $post_id ); + if ( ! $this->should_handle_event_post( $post_id, $post ) ) { + return; + } + + $this->queue_schedule_update( $post_id ); + } + + /** + * Queue an event for one final schedule comparison at shutdown. + * + * @param int $post_id Event post id. + * @return void + */ + private function queue_schedule_update( $post_id ) { + $post_id = absint( $post_id ); + if ( $post_id <= 0 ) { + return; + } + + if ( ! in_array( $post_id, $this->pending_schedule_post_ids, true ) ) { + $this->pending_schedule_post_ids[] = $post_id; + } + } + + /** + * Process all queued schedule updates once the request has settled. + * + * @return void + */ + public function process_pending_schedule_updates() { + if ( empty( $this->pending_schedule_post_ids ) ) { + return; + } + + $post_ids = $this->pending_schedule_post_ids; + $this->pending_schedule_post_ids = array(); + + foreach ( $post_ids as $post_id ) { + $post = get_post( $post_id ); + if ( ! $this->should_handle_event_post( $post_id, $post ) ) { + continue; + } + + $current_snapshot = $this->build_event_schedule_snapshot( $post_id ); + $previous_snapshot = $this->get_stored_schedule_snapshot( $post_id ); + + if ( empty( $current_snapshot['local_iso'] ) && empty( $current_snapshot['venue']['name'] ) && empty( $current_snapshot['teams'] ) ) { + continue; + } + + if ( empty( $previous_snapshot ) ) { + $this->store_schedule_snapshot( $post_id, $current_snapshot ); + continue; + } + + if ( $this->schedule_snapshots_match( $previous_snapshot, $current_snapshot ) ) { + $this->store_schedule_snapshot( $post_id, $current_snapshot ); + continue; + } + + $this->store_schedule_snapshot( $post_id, $current_snapshot ); + + $this->dispatch_trigger( + 'event_datetime_changed', + $post_id, + array( + 'previous' => $previous_snapshot, + 'current' => $current_snapshot, + ) + ); + } } /** @@ -569,7 +730,7 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { return array( array( 'tag' => '{{ trigger.key }}', - 'description' => __( 'Trigger slug such as event_datetime_changed or event_results_updated.', 'tonys-sportspress-enhancements' ), + 'description' => __( 'Trigger slug such as event_datetime_changed or event_results_updated. The schedule trigger includes date, time, place, and team changes.', 'tonys-sportspress-enhancements' ), ), array( 'tag' => '{{ trigger.label }}', @@ -595,6 +756,10 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { 'tag' => '{{ event.permalink }}', 'description' => __( 'Public event permalink.', 'tonys-sportspress-enhancements' ), ), + array( + 'tag' => '{{ event.status }}', + 'description' => __( 'Current SportsPress schedule status such as On time, TBD, Postponed, or Canceled.', 'tonys-sportspress-enhancements' ), + ), array( 'tag' => '{{ event.image }}', 'description' => __( 'Same matchup image URL used in the Open Graph tags.', 'tonys-sportspress-enhancements' ), @@ -605,27 +770,119 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { ), array( 'tag' => '{{ event.scheduled.local_display }}', - 'description' => __( 'Scheduled date/time in the site timezone.', 'tonys-sportspress-enhancements' ), + 'description' => __( 'Scheduled date/time in the venue timezone (Central Time).', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ event.scheduled.date }}', + 'description' => __( 'Scheduled date in the venue timezone.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ event.scheduled.time }}', + 'description' => __( 'Scheduled time in the venue timezone.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ event.scheduled.timezone }}', + 'description' => __( 'Venue timezone abbreviation such as CST or CDT.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ event.scheduled.timestamp|date("g:i A") }}', + 'description' => __( 'Format a timestamp with a PHP date format string in the venue timezone.', 'tonys-sportspress-enhancements' ), ), array( 'tag' => '{{ event.teams.0.name }}', 'description' => __( 'First team name in event order.', 'tonys-sportspress-enhancements' ), ), + array( + 'tag' => '{{ event.home_team.name }}', + 'description' => __( 'Current home team name.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ event.away_team.name }}', + 'description' => __( 'Current away team name.', 'tonys-sportspress-enhancements' ), + ), array( 'tag' => '{{ event.venue.name }}', 'description' => __( 'Primary venue name.', 'tonys-sportspress-enhancements' ), ), + array( + 'tag' => '{{ event.venue.short_name }}', + 'description' => __( 'Primary venue short name.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ event.venue.abbreviation }}', + 'description' => __( 'Primary venue abbreviation.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ event.field.name }}', + 'description' => __( 'Alias for the primary venue name.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ event.field.short_name }}', + 'description' => __( 'Alias for the primary venue short name.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ event.field.abbreviation }}', + 'description' => __( 'Alias for the primary venue abbreviation.', 'tonys-sportspress-enhancements' ), + ), array( 'tag' => '{{ results.summary }}', 'description' => __( 'Result summary string when scores exist.', 'tonys-sportspress-enhancements' ), ), array( 'tag' => '{{ changes.previous.local_display }}', - 'description' => __( 'Previous scheduled date/time for date/time change notifications.', 'tonys-sportspress-enhancements' ), + 'description' => __( 'Previous scheduled date/time for change notifications, in the venue timezone.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ changes.previous.time }}', + 'description' => __( 'Previous scheduled time for change notifications, in the venue timezone.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ changes.previous.status }}', + 'description' => __( 'Previous SportsPress schedule status when it changed.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ changes.previous.venue.name }}', + 'description' => __( 'Previous venue name when the field/venue changed.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ changes.previous.field.name }}', + 'description' => __( 'Alias for the previous venue name.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ changes.previous.home_team.name }}', + 'description' => __( 'Previous home team name when a team changed.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ changes.previous.away_team.name }}', + 'description' => __( 'Previous away team name when a team changed.', 'tonys-sportspress-enhancements' ), ), array( 'tag' => '{{ changes.current.local_display }}', - 'description' => __( 'Current scheduled date/time for date/time change notifications.', 'tonys-sportspress-enhancements' ), + 'description' => __( 'Current scheduled date/time for change notifications, in the venue timezone.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ changes.current.time }}', + 'description' => __( 'Current scheduled time for change notifications, in the venue timezone.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ changes.current.status }}', + 'description' => __( 'Current SportsPress schedule status when it changed.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ changes.current.venue.name }}', + 'description' => __( 'Current venue name when the field/venue changed.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ changes.current.field.name }}', + 'description' => __( 'Alias for the current venue name.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ changes.current.home_team.name }}', + 'description' => __( 'Current home team name when a team changed.', 'tonys-sportspress-enhancements' ), + ), + array( + 'tag' => '{{ changes.current.away_team.name }}', + 'description' => __( 'Current away team name when a team changed.', 'tonys-sportspress-enhancements' ), ), array( 'tag' => '{{ occurred_at.local_display }}', @@ -766,7 +1023,7 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { */ private function trigger_labels() { return array( - 'event_datetime_changed' => __( 'Game date/time changes', 'tonys-sportspress-enhancements' ), + 'event_datetime_changed' => __( 'Schedule changes', 'tonys-sportspress-enhancements' ), 'event_results_updated' => __( 'Results updated', 'tonys-sportspress-enhancements' ), ); } @@ -780,6 +1037,54 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { return "{{ trigger.label }}\n{{ event.title }}\n{{ event.scheduled.local_display }}\n{{ results.summary }}\n{{ event.permalink }}"; } + /** + * Reset stored schedule snapshots for all SportsPress events from the admin UI. + * + * @return void + */ + public function handle_reset_schedule_snapshots_admin() { + if ( ! current_user_can( 'manage_sportspress' ) ) { + wp_die( esc_html__( 'You do not have permission to reset webhook schedule snapshots.', 'tonys-sportspress-enhancements' ), 403 ); + } + + check_admin_referer( 'tse_sp_reset_schedule_snapshots' ); + + $event_ids = get_posts( + array( + 'post_type' => 'sp_event', + 'post_status' => array( 'publish', 'future', 'draft', 'pending', 'private' ), + 'posts_per_page' => -1, + 'fields' => 'ids', + 'no_found_rows' => true, + ) + ); + + $reset_count = 0; + foreach ( $event_ids as $event_id ) { + $event_id = absint( $event_id ); + if ( $event_id <= 0 ) { + continue; + } + + delete_post_meta( $event_id, self::SCHEDULE_SNAPSHOT_META_KEY ); + delete_post_meta( $event_id, self::SCHEDULE_SIGNATURE_META_KEY ); + $this->store_schedule_snapshot( $event_id, $this->build_event_schedule_snapshot( $event_id ) ); + ++$reset_count; + } + + $redirect_url = add_query_arg( + array( + 'tab' => self::TAB_WEBHOOKS, + 'tse_sp_snapshots_reset' => 1, + 'tse_sp_snapshots_reset_count' => $reset_count, + ), + wp_get_referer() ? wp_get_referer() : admin_url() + ); + + wp_safe_redirect( $redirect_url ); + exit; + } + /** * Handle an admin AJAX test-send request. * @@ -797,11 +1102,12 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { check_ajax_referer( 'tse_sp_webhook_test', 'nonce' ); - $raw_settings = isset( $_POST[ self::OPTION_KEY ] ) ? wp_unslash( $_POST[ self::OPTION_KEY ] ) : array(); - $rows = is_array( $raw_settings ) && isset( $raw_settings['webhooks'] ) && is_array( $raw_settings['webhooks'] ) ? array_values( $raw_settings['webhooks'] ) : array(); - $row = isset( $rows[0] ) && is_array( $rows[0] ) ? $rows[0] : array(); - $test_events = isset( $_POST['tse_sp_webhook_test_event'] ) && is_array( $_POST['tse_sp_webhook_test_event'] ) ? wp_unslash( $_POST['tse_sp_webhook_test_event'] ) : array(); - $test_event_id = isset( $test_events[0] ) ? absint( $test_events[0] ) : 0; + $raw_settings = isset( $_POST[ self::OPTION_KEY ] ) ? wp_unslash( $_POST[ self::OPTION_KEY ] ) : array(); + $rows = is_array( $raw_settings ) && isset( $raw_settings['webhooks'] ) && is_array( $raw_settings['webhooks'] ) ? $raw_settings['webhooks'] : array(); + $test_events = isset( $_POST['tse_sp_webhook_test_event'] ) && is_array( $_POST['tse_sp_webhook_test_event'] ) ? wp_unslash( $_POST['tse_sp_webhook_test_event'] ) : array(); + $submitted_row = $this->get_submitted_test_webhook_row( $rows, $test_events ); + $row = isset( $submitted_row['row'] ) && is_array( $submitted_row['row'] ) ? $submitted_row['row'] : array(); + $test_event_id = isset( $submitted_row['event_id'] ) ? (int) $submitted_row['event_id'] : 0; if ( empty( $row ) ) { wp_send_json_error( @@ -845,6 +1151,36 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { ); } + /** + * Get the submitted webhook row and selected test event from the AJAX payload. + * + * @param array $rows Submitted webhook rows keyed by row index. + * @param array $test_events Submitted test event ids keyed by row index. + * @return array{row: array, event_id: int} + */ + private function get_submitted_test_webhook_row( $rows, $test_events ) { + if ( ! is_array( $rows ) || empty( $rows ) ) { + return array( + 'row' => array(), + 'event_id' => 0, + ); + } + + foreach ( $rows as $row_index => $row ) { + if ( is_array( $row ) ) { + return array( + 'row' => $row, + 'event_id' => isset( $test_events[ $row_index ] ) ? absint( $test_events[ $row_index ] ) : 0, + ); + } + } + + return array( + 'row' => array(), + 'event_id' => 0, + ); + } + /** * Sanitize a provider-specific destination. * @@ -1003,9 +1339,11 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { * @return array */ private function event_schedule_from_post( $post ) { - $timezone = wp_timezone(); $utc = new DateTimeZone( 'UTC' ); $empty = array( + 'date' => '', + 'time' => '', + 'timezone' => '', 'local_iso' => '', 'local_display' => '', 'gmt_iso' => '', @@ -1018,6 +1356,7 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { $local = null; $gmt = null; + $timezone = $this->get_event_timezone(); if ( ! empty( $post->post_date_gmt ) && '0000-00-00 00:00:00' !== $post->post_date_gmt ) { $gmt = new DateTimeImmutable( $post->post_date_gmt, $utc ); @@ -1031,12 +1370,7 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { return $empty; } - return array( - 'local_iso' => $local->format( DATE_ATOM ), - 'local_display' => wp_date( 'Y-m-d g:i A T', $local->getTimestamp(), $timezone ), - 'gmt_iso' => $gmt->format( DATE_ATOM ), - 'timestamp' => $local->getTimestamp(), - ); + return $this->build_schedule_data( $local->getTimestamp(), $gmt->format( DATE_ATOM ) ); } /** @@ -1052,12 +1386,14 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { $post = get_post( $post_id ); $schedule = $this->event_schedule_from_post( $post ); $results = get_post_meta( $post_id, 'sp_results', true ); - $teams = $this->get_event_teams( $post_id ); - $venue = $this->get_event_venue( $post_id ); - $event_title = $this->get_event_title( $post_id ); - $results_arr = is_array( $results ) ? $results : array(); - $results_sum = $this->get_results_summary( $post ); - $now = new DateTimeImmutable( 'now', wp_timezone() ); + $teams = $this->get_event_teams( $post_id ); + $venue = $this->get_event_venue( $post_id ); + $event_title = $this->get_event_title( $post_id ); + $event_status_raw = (string) get_post_meta( $post_id, 'sp_status', true ); + $event_status = $this->get_event_status_label( $event_status_raw ); + $results_arr = is_array( $results ) ? $results : array(); + $results_sum = $this->get_results_summary( $post ); + $now = new DateTimeImmutable( 'now', wp_timezone() ); $context = array( 'trigger' => array( @@ -1083,10 +1419,14 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { 'matchup_image' => $post instanceof WP_Post && function_exists( 'asc_sp_event_matchup_image_url' ) ? asc_sp_event_matchup_image_url( $post ) : '', 'edit_url' => $post instanceof WP_Post ? get_edit_post_link( $post->ID, 'raw' ) : '', 'post_status' => $post instanceof WP_Post ? (string) $post->post_status : '', - 'sp_status' => (string) get_post_meta( $post_id, 'sp_status', true ), + 'status' => $event_status, + 'sp_status' => $this->normalize_event_status_key( $event_status_raw ), 'scheduled' => $schedule, 'teams' => $teams, + 'home_team' => isset( $teams[0] ) ? $teams[0] : $this->normalize_single_team_data( array() ), + 'away_team' => isset( $teams[1] ) ? $teams[1] : $this->normalize_single_team_data( array() ), 'venue' => $venue, + 'field' => $venue, ), 'changes' => is_array( $changes ) ? $changes : array(), 'results' => array( @@ -1127,7 +1467,7 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { } $labels = $this->trigger_labels(); - $now = new DateTimeImmutable( 'now', wp_timezone() ); + $now = new DateTimeImmutable( 'now', $this->get_event_timezone() ); $next = $now->modify( '+2 hours' ); return array( @@ -1154,13 +1494,9 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { 'matchup_image' => home_url( '/head-to-head?post=0' ), 'edit_url' => admin_url( 'edit.php?post_type=sp_event' ), 'post_status' => 'publish', - 'sp_status' => 'future', - 'scheduled' => array( - 'local_iso' => $next->format( DATE_ATOM ), - 'local_display' => wp_date( 'Y-m-d g:i A T', $next->getTimestamp(), wp_timezone() ), - 'gmt_iso' => $next->setTimezone( new DateTimeZone( 'UTC' ) )->format( DATE_ATOM ), - 'timestamp' => $next->getTimestamp(), - ), + 'status' => 'On time', + 'sp_status' => 'ok', + 'scheduled' => $this->build_schedule_data( $next->getTimestamp() ), 'teams' => array( array( 'id' => 0, @@ -1177,24 +1513,89 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { 'role' => 'away', ), ), + 'home_team' => array( + 'id' => 0, + 'name' => __( 'Home Team', 'tonys-sportspress-enhancements' ), + 'short_name' => __( 'Home', 'tonys-sportspress-enhancements' ), + 'abbreviation' => 'HOME', + 'role' => 'home', + ), + 'away_team' => array( + 'id' => 0, + 'name' => __( 'Away Team', 'tonys-sportspress-enhancements' ), + 'short_name' => __( 'Away', 'tonys-sportspress-enhancements' ), + 'abbreviation' => 'AWAY', + 'role' => 'away', + ), 'venue' => array( - 'id' => 0, - 'name' => __( 'Sample Field', 'tonys-sportspress-enhancements' ), - 'slug' => 'sample-field', + 'id' => 0, + 'name' => __( 'Sample Field', 'tonys-sportspress-enhancements' ), + 'short_name' => __( 'Sample', 'tonys-sportspress-enhancements' ), + 'abbreviation' => 'SF', + 'slug' => 'sample-field', + ), + 'field' => array( + 'id' => 0, + 'name' => __( 'Sample Field', 'tonys-sportspress-enhancements' ), + 'short_name' => __( 'Sample', 'tonys-sportspress-enhancements' ), + 'abbreviation' => 'SF', + 'slug' => 'sample-field', ), ), 'changes' => array( - 'previous' => array( - 'local_iso' => $now->format( DATE_ATOM ), - 'local_display' => wp_date( 'Y-m-d g:i A T', $now->getTimestamp(), wp_timezone() ), - 'gmt_iso' => $now->setTimezone( new DateTimeZone( 'UTC' ) )->format( DATE_ATOM ), - 'timestamp' => $now->getTimestamp(), + 'previous' => $this->build_change_snapshot( + $this->build_schedule_data( $now->getTimestamp() ), + array( + 'id' => 0, + 'name' => __( 'Old Field', 'tonys-sportspress-enhancements' ), + 'short_name' => __( 'Old', 'tonys-sportspress-enhancements' ), + 'abbreviation' => 'OF', + 'slug' => 'old-field', + ), + array( + array( + 'id' => 0, + 'name' => __( 'Old Home', 'tonys-sportspress-enhancements' ), + 'short_name' => __( 'Old Home', 'tonys-sportspress-enhancements' ), + 'abbreviation' => 'OH', + 'role' => 'home', + ), + array( + 'id' => 0, + 'name' => __( 'Away Team', 'tonys-sportspress-enhancements' ), + 'short_name' => __( 'Away', 'tonys-sportspress-enhancements' ), + 'abbreviation' => 'AWAY', + 'role' => 'away', + ), + ), + 'TBD' ), - 'current' => array( - 'local_iso' => $next->format( DATE_ATOM ), - 'local_display' => wp_date( 'Y-m-d g:i A T', $next->getTimestamp(), wp_timezone() ), - 'gmt_iso' => $next->setTimezone( new DateTimeZone( 'UTC' ) )->format( DATE_ATOM ), - 'timestamp' => $next->getTimestamp(), + 'current' => $this->build_change_snapshot( + $this->build_schedule_data( $next->getTimestamp() ), + array( + 'id' => 0, + 'name' => __( 'Sample Field', 'tonys-sportspress-enhancements' ), + 'short_name' => __( 'Sample', 'tonys-sportspress-enhancements' ), + 'abbreviation' => 'SF', + 'slug' => 'sample-field', + ), + array( + array( + 'id' => 0, + 'name' => __( 'Home Team', 'tonys-sportspress-enhancements' ), + 'short_name' => __( 'Home', 'tonys-sportspress-enhancements' ), + 'abbreviation' => 'HOME', + 'role' => 'home', + ), + array( + 'id' => 0, + 'name' => __( 'Away Team', 'tonys-sportspress-enhancements' ), + 'short_name' => __( 'Away', 'tonys-sportspress-enhancements' ), + 'abbreviation' => 'AWAY', + 'role' => 'away', + ), + ), + 'On time' ), ), 'results' => array( @@ -1226,17 +1627,16 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { if ( 'event_datetime_changed' === $trigger ) { $previous = $schedule; + $venue = $this->get_event_venue( $event_id ); + $status = (string) get_post_meta( $event_id, 'sp_status', true ); if ( ! empty( $schedule['timestamp'] ) ) { $previous_timestamp = max( 0, (int) $schedule['timestamp'] - HOUR_IN_SECONDS ); - $previous['timestamp'] = $previous_timestamp; - $previous['local_iso'] = wp_date( DATE_ATOM, $previous_timestamp, wp_timezone() ); - $previous['local_display'] = wp_date( 'Y-m-d g:i A T', $previous_timestamp, wp_timezone() ); - $previous['gmt_iso'] = gmdate( DATE_ATOM, $previous_timestamp ); + $previous = $this->build_schedule_data( $previous_timestamp ); } $changes = array( - 'previous' => $previous, - 'current' => $schedule, + 'previous' => $this->build_change_snapshot( $previous, $venue, $this->get_event_teams( $event_id ), $status ), + 'current' => $this->build_change_snapshot( $schedule, $venue, $this->get_event_teams( $event_id ), $status ), ); } elseif ( 'event_results_updated' === $trigger ) { $results = get_post_meta( $event_id, 'sp_results', true ); @@ -1507,7 +1907,8 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { /** * Render Jinja-style placeholders with a minimal dot-path syntax. * - * Supports `{{ event.title }}` and `{{ event|tojson }}`. + * Supports `{{ event.title }}`, `{{ event|tojson }}`, `{% if changes.previous.time != changes.current.time %}`, + * and `{{ event.scheduled.timestamp|date("g:i A") }}`. * * @param string $template Template body. * @param array $context Template context. @@ -1516,6 +1917,7 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { public function render_template( $template, $context ) { $template = (string) $template; $context = is_array( $context ) ? $context : array(); + $template = $this->render_template_conditionals( $template, $context ); return preg_replace_callback( '/\{\{\s*(.+?)\s*\}\}/', @@ -1525,15 +1927,19 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { $path = array_shift( $parts ); $value = $this->resolve_context_path( $context, $path ); - $force_json = false; foreach ( $parts as $filter ) { - if ( in_array( strtolower( $filter ), array( 'tojson', 'json' ), true ) ) { - $force_json = true; - } - } + $filter_name = strtolower( trim( (string) $filter ) ); - if ( $force_json ) { - return (string) wp_json_encode( $value ); + if ( in_array( $filter_name, array( 'tojson', 'json' ), true ) ) { + return (string) wp_json_encode( $value ); + } + + if ( 0 === strpos( $filter_name, 'date' ) ) { + $format = $this->extract_date_filter_format( $filter ); + if ( '' !== $format ) { + $value = $this->format_template_date_value( $value, $format ); + } + } } if ( is_array( $value ) || is_object( $value ) ) { @@ -1554,6 +1960,468 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { ); } + /** + * Render simple conditional blocks before placeholder interpolation. + * + * Supports `{% if foo %}...{% endif %}`, `{% if foo == "bar" %}...{% else %}...{% endif %}`, + * `{% if foo != bar %}...{% endif %}`, and simple `or` expressions. + * + * @param string $template Template body. + * @param array $context Template context. + * @return string + */ + private function render_template_conditionals( $template, $context ) { + $template = (string) $template; + $context = is_array( $context ) ? $context : array(); + $pattern = '/\{%\s*if\s+(.+?)\s*%\}(?:(?!\{%\s*if\b).)*?(?:\{%\s*endif\s*%\})/s'; + $previous = null; + + while ( $template !== $previous && preg_match( $pattern, $template ) ) { + $previous = $template; + $template = preg_replace_callback( + '/\{%\s*if\s+(.+?)\s*%\}(.*?)(?:\{%\s*else\s*%\}(.*?))?\{%\s*endif\s*%\}/s', + function ( $matches ) use ( $context ) { + $condition = isset( $matches[1] ) ? trim( (string) $matches[1] ) : ''; + $when_true = isset( $matches[2] ) ? (string) $matches[2] : ''; + $when_false = isset( $matches[3] ) ? (string) $matches[3] : ''; + + if ( $this->evaluate_template_condition( $condition, $context ) ) { + return $when_true; + } + + return $when_false; + }, + $template + ); + } + + return $template; + } + + /** + * Evaluate a simple template conditional expression. + * + * @param string $condition Condition expression. + * @param array $context Template context. + * @return bool + */ + private function evaluate_template_condition( $condition, $context ) { + $condition = trim( (string) $condition ); + if ( '' === $condition ) { + return false; + } + + if ( preg_match( '/\s+or\s+/i', $condition ) ) { + $parts = preg_split( '/\s+or\s+/i', $condition ); + if ( is_array( $parts ) ) { + foreach ( $parts as $part ) { + if ( $this->evaluate_template_condition( $part, $context ) ) { + return true; + } + } + } + + return false; + } + + if ( preg_match( '/^(.+?)\s*(==|!=)\s*(.+)$/', $condition, $matches ) ) { + $left = $this->resolve_template_condition_operand( isset( $matches[1] ) ? (string) $matches[1] : '', $context ); + $right = $this->resolve_template_condition_operand( isset( $matches[3] ) ? (string) $matches[3] : '', $context ); + $operator = isset( $matches[2] ) ? (string) $matches[2] : '=='; + + if ( '!=' === $operator ) { + return $left !== $right; + } + + return $left === $right; + } + + return $this->is_truthy_template_value( $this->resolve_template_condition_operand( $condition, $context ) ); + } + + /** + * Resolve one side of a template conditional. + * + * @param string $operand Operand expression. + * @param array $context Template context. + * @return mixed + */ + private function resolve_template_condition_operand( $operand, $context ) { + $operand = trim( (string) $operand ); + if ( preg_match( '/^([\'"])(.*)\1$/s', $operand, $matches ) ) { + return isset( $matches[2] ) ? (string) $matches[2] : ''; + } + + $lower = strtolower( $operand ); + if ( 'true' === $lower ) { + return true; + } + + if ( 'false' === $lower ) { + return false; + } + + if ( 'null' === $lower ) { + return null; + } + + if ( is_numeric( $operand ) ) { + return false !== strpos( $operand, '.' ) ? (float) $operand : (int) $operand; + } + + return $this->resolve_context_path( $context, $operand ); + } + + /** + * Determine truthiness for a template conditional. + * + * @param mixed $value Value to inspect. + * @return bool + */ + private function is_truthy_template_value( $value ) { + if ( is_array( $value ) ) { + return ! empty( $value ); + } + + if ( is_string( $value ) ) { + return '' !== trim( $value ); + } + + return ! empty( $value ); + } + + /** + * Extract a PHP date format string from a template filter. + * + * Supports `date("g:i A")`, `date('g:i A')`, and `date:g:i A`. + * + * @param string $filter Filter expression. + * @return string + */ + private function extract_date_filter_format( $filter ) { + $filter = trim( (string) $filter ); + + if ( preg_match( '/^date\s*\(\s*([\'"])(.*?)\1\s*\)$/i', $filter, $matches ) ) { + return isset( $matches[2] ) ? (string) $matches[2] : ''; + } + + if ( preg_match( '/^date\s*:\s*(.+)$/i', $filter, $matches ) ) { + return isset( $matches[1] ) ? trim( (string) $matches[1] ) : ''; + } + + return ''; + } + + /** + * Format a template value as a date/time string in the venue timezone. + * + * @param mixed $value Template value. + * @param string $format PHP date format string. + * @return string + */ + private function format_template_date_value( $value, $format ) { + $format = (string) $format; + if ( '' === $format ) { + return ''; + } + + $timestamp = 0; + + if ( is_numeric( $value ) ) { + $timestamp = (int) $value; + } elseif ( is_string( $value ) && '' !== trim( $value ) ) { + try { + $date = new DateTimeImmutable( $value ); + $timestamp = $date->getTimestamp(); + } catch ( Exception $exception ) { + unset( $exception ); + return ''; + } + } + + if ( $timestamp <= 0 ) { + return ''; + } + + return wp_date( $format, $timestamp, $this->get_event_timezone() ); + } + + /** + * Build a normalized before/after snapshot for change notifications. + * + * @param array $schedule Schedule data. + * @param array $venue Venue data. + * @param array $teams Team data. + * @param string $status SportsPress schedule status. + * @return array + */ + private function build_change_snapshot( $schedule, $venue, $teams = array(), $status = '' ) { + $schedule = is_array( $schedule ) ? $schedule : array(); + $venue = is_array( $venue ) ? $venue : array(); + $teams = $this->normalize_team_data( $teams ); + $status = $this->normalize_event_status_key( $status ); + + return array_merge( + $this->build_schedule_data( isset( $schedule['timestamp'] ) ? (int) $schedule['timestamp'] : 0, isset( $schedule['gmt_iso'] ) ? (string) $schedule['gmt_iso'] : null ), + $schedule, + array( + 'status' => $this->get_event_status_label( $status ), + 'sp_status' => $status, + 'venue' => $this->normalize_venue_data( $venue ), + 'field' => $this->normalize_venue_data( $venue ), + 'teams' => $teams, + 'home_team' => isset( $teams[0] ) ? $teams[0] : $this->normalize_single_team_data( array() ), + 'away_team' => isset( $teams[1] ) ? $teams[1] : $this->normalize_single_team_data( array() ), + ) + ); + } + + /** + * Normalize venue data for template context usage. + * + * @param array $venue Venue data. + * @return array + */ + private function normalize_venue_data( $venue ) { + return array( + 'id' => isset( $venue['id'] ) ? (int) $venue['id'] : 0, + 'name' => isset( $venue['name'] ) ? (string) $venue['name'] : '', + 'short_name' => isset( $venue['short_name'] ) ? (string) $venue['short_name'] : '', + 'abbreviation' => isset( $venue['abbreviation'] ) ? (string) $venue['abbreviation'] : '', + 'slug' => isset( $venue['slug'] ) ? (string) $venue['slug'] : '', + ); + } + + /** + * Determine whether two venue payloads are effectively the same. + * + * @param array $left First venue. + * @param array $right Second venue. + * @return bool + */ + private function venues_match( $left, $right ) { + return $this->normalize_venue_data( is_array( $left ) ? $left : array() ) === $this->normalize_venue_data( is_array( $right ) ? $right : array() ); + } + + /** + * Normalize team data for template context usage. + * + * @param array $teams Team data. + * @return array + */ + private function normalize_team_data( $teams ) { + if ( ! is_array( $teams ) ) { + return array(); + } + + $normalized = array(); + foreach ( $teams as $team ) { + $normalized[] = $this->normalize_single_team_data( is_array( $team ) ? $team : array() ); + } + + return $normalized; + } + + /** + * Normalize a single team payload for template context usage. + * + * @param array $team Team data. + * @return array + */ + private function normalize_single_team_data( $team ) { + return array( + 'id' => isset( $team['id'] ) ? (int) $team['id'] : 0, + 'name' => isset( $team['name'] ) ? (string) $team['name'] : '', + 'short_name' => isset( $team['short_name'] ) ? (string) $team['short_name'] : '', + 'abbreviation' => isset( $team['abbreviation'] ) ? (string) $team['abbreviation'] : '', + 'role' => isset( $team['role'] ) ? (string) $team['role'] : '', + ); + } + + /** + * Determine whether two team lists are effectively the same. + * + * @param array $left First team list. + * @param array $right Second team list. + * @return bool + */ + private function teams_match( $left, $right ) { + return $this->normalize_team_data( is_array( $left ) ? $left : array() ) === $this->normalize_team_data( is_array( $right ) ? $right : array() ); + } + + /** + * Get the SportsPress event status label map. + * + * @return array + */ + private function get_event_status_labels() { + return apply_filters( + 'sportspress_event_statuses', + array( + 'ok' => esc_attr__( 'On time', 'sportspress' ), + 'tbd' => esc_attr__( 'TBD', 'sportspress' ), + 'postponed' => esc_attr__( 'Postponed', 'sportspress' ), + 'cancelled' => esc_attr__( 'Canceled', 'sportspress' ), + ) + ); + } + + /** + * Normalize a SportsPress event status into its stored key. + * + * @param string $status Status key or label. + * @return string + */ + private function normalize_event_status_key( $status ) { + $status = is_string( $status ) ? trim( $status ) : ''; + if ( '' === $status ) { + return 'ok'; + } + + $lower = strtolower( $status ); + $map = $this->get_event_status_labels(); + + if ( isset( $map[ $lower ] ) ) { + return $lower; + } + + foreach ( $map as $key => $label ) { + if ( strtolower( wp_strip_all_tags( (string) $label ) ) === $lower ) { + return (string) $key; + } + } + + if ( 'canceled' === $lower ) { + return 'cancelled'; + } + + return sanitize_key( $lower ); + } + + /** + * Get the display label for a SportsPress event status. + * + * @param string $status Status key or label. + * @return string + */ + private function get_event_status_label( $status ) { + $key = $this->normalize_event_status_key( $status ); + $statuses = $this->get_event_status_labels(); + + if ( isset( $statuses[ $key ] ) ) { + return (string) wp_strip_all_tags( $statuses[ $key ] ); + } + + return (string) $status; + } + + /** + * Build a stable signature for the current schedule state. + * + * @param array $schedule Schedule data. + * @param array $venue Venue data. + * @param array $teams Team data. + * @param string $status SportsPress schedule status. + * @return string + */ + private function build_schedule_change_signature( $schedule, $venue, $teams, $status = '' ) { + $schedule = is_array( $schedule ) ? $schedule : array(); + $venue = $this->normalize_venue_data( is_array( $venue ) ? $venue : array() ); + $teams = $this->normalize_team_data( $teams ); + $status = $this->normalize_event_status_key( $status ); + + return md5( + wp_json_encode( + array( + 'gmt_iso' => isset( $schedule['gmt_iso'] ) ? (string) $schedule['gmt_iso'] : '', + 'local_iso' => isset( $schedule['local_iso'] ) ? (string) $schedule['local_iso'] : '', + 'status' => $status, + 'venue' => $venue, + 'teams' => $teams, + ) + ) + ); + } + + /** + * Build the current schedule snapshot for an event. + * + * @param int $post_id Event post id. + * @return array + */ + private function build_event_schedule_snapshot( $post_id ) { + $post = get_post( $post_id ); + $schedule = $this->event_schedule_from_post( $post ); + $venue = $this->get_event_venue( $post_id ); + $teams = $this->get_event_teams( $post_id ); + $status = (string) get_post_meta( $post_id, 'sp_status', true ); + + return $this->build_change_snapshot( $schedule, $venue, $teams, $status ); + } + + /** + * Get the stored previous schedule snapshot for an event. + * + * @param int $post_id Event post id. + * @return array + */ + private function get_stored_schedule_snapshot( $post_id ) { + $stored = get_post_meta( $post_id, self::SCHEDULE_SNAPSHOT_META_KEY, true ); + if ( ! is_array( $stored ) ) { + return array(); + } + + return $this->build_change_snapshot( + is_array( $stored ) ? $stored : array(), + isset( $stored['venue'] ) && is_array( $stored['venue'] ) ? $stored['venue'] : array(), + isset( $stored['teams'] ) && is_array( $stored['teams'] ) ? $stored['teams'] : array(), + isset( $stored['status'] ) ? (string) $stored['status'] : ( isset( $stored['sp_status'] ) ? (string) $stored['sp_status'] : '' ) + ); + } + + /** + * Store the current schedule snapshot and derived signature. + * + * @param int $post_id Event post id. + * @param array $snapshot Schedule snapshot. + * @return void + */ + private function store_schedule_snapshot( $post_id, $snapshot ) { + $snapshot = is_array( $snapshot ) ? $snapshot : array(); + update_post_meta( $post_id, self::SCHEDULE_SNAPSHOT_META_KEY, $snapshot ); + update_post_meta( + $post_id, + self::SCHEDULE_SIGNATURE_META_KEY, + $this->build_schedule_change_signature( + $snapshot, + isset( $snapshot['venue'] ) && is_array( $snapshot['venue'] ) ? $snapshot['venue'] : array(), + isset( $snapshot['teams'] ) && is_array( $snapshot['teams'] ) ? $snapshot['teams'] : array(), + isset( $snapshot['status'] ) ? (string) $snapshot['status'] : ( isset( $snapshot['sp_status'] ) ? (string) $snapshot['sp_status'] : '' ) + ) + ); + } + + /** + * Determine whether two stored schedule snapshots are the same. + * + * @param array $left First snapshot. + * @param array $right Second snapshot. + * @return bool + */ + private function schedule_snapshots_match( $left, $right ) { + return $this->build_schedule_change_signature( + is_array( $left ) ? $left : array(), + isset( $left['venue'] ) && is_array( $left['venue'] ) ? $left['venue'] : array(), + isset( $left['teams'] ) && is_array( $left['teams'] ) ? $left['teams'] : array(), + isset( $left['status'] ) ? (string) $left['status'] : ( isset( $left['sp_status'] ) ? (string) $left['sp_status'] : '' ) + ) === $this->build_schedule_change_signature( + is_array( $right ) ? $right : array(), + isset( $right['venue'] ) && is_array( $right['venue'] ) ? $right['venue'] : array(), + isset( $right['teams'] ) && is_array( $right['teams'] ) ? $right['teams'] : array(), + isset( $right['status'] ) ? (string) $right['status'] : ( isset( $right['sp_status'] ) ? (string) $right['sp_status'] : '' ) + ); + } + /** * Resolve a dot-path from the render context. * @@ -1688,19 +2556,113 @@ if ( ! class_exists( 'Tony_Sportspress_Webhooks' ) ) { private function get_event_venue( $post_id ) { $terms = get_the_terms( $post_id, 'sp_venue' ); if ( is_wp_error( $terms ) || empty( $terms ) ) { - return array( - 'id' => 0, - 'name' => '', - 'slug' => '', - ); + return $this->normalize_venue_data( array() ); } $venue = reset( $terms ); + return $this->normalize_venue_data( + array( + 'id' => isset( $venue->term_id ) ? (int) $venue->term_id : 0, + 'name' => isset( $venue->name ) ? (string) $venue->name : '', + 'short_name' => isset( $venue->term_id ) ? trim( (string) get_term_meta( $venue->term_id, 'tse_short_name', true ) ) : '', + 'abbreviation' => isset( $venue->term_id ) ? trim( (string) get_term_meta( $venue->term_id, 'tse_abbreviation', true ) ) : '', + 'slug' => isset( $venue->slug ) ? (string) $venue->slug : '', + ) + ); + } + + /** + * Get a venue payload from previous term taxonomy ids. + * + * @param array $term_taxonomy_ids Previous term taxonomy ids. + * @param string $taxonomy Taxonomy slug. + * @return array + */ + private function get_venue_from_term_taxonomy_ids( $term_taxonomy_ids, $taxonomy ) { + if ( ! is_array( $term_taxonomy_ids ) || empty( $term_taxonomy_ids ) ) { + return $this->normalize_venue_data( array() ); + } + + $term_taxonomy_ids = array_values( array_filter( array_map( 'intval', $term_taxonomy_ids ) ) ); + if ( empty( $term_taxonomy_ids ) ) { + return $this->normalize_venue_data( array() ); + } + + $terms = get_terms( + array( + 'taxonomy' => $taxonomy, + 'hide_empty' => false, + 'fields' => 'all', + 'meta_query' => array(), + ) + ); + + if ( is_wp_error( $terms ) || ! is_array( $terms ) ) { + return $this->normalize_venue_data( array() ); + } + + foreach ( $terms as $term ) { + if ( ! $term instanceof WP_Term || ! isset( $term->term_taxonomy_id ) ) { + continue; + } + + if ( in_array( (int) $term->term_taxonomy_id, $term_taxonomy_ids, true ) ) { + return $this->normalize_venue_data( + array( + 'id' => isset( $term->term_id ) ? (int) $term->term_id : 0, + 'name' => isset( $term->name ) ? (string) $term->name : '', + 'short_name' => isset( $term->term_id ) ? trim( (string) get_term_meta( $term->term_id, 'tse_short_name', true ) ) : '', + 'abbreviation' => isset( $term->term_id ) ? trim( (string) get_term_meta( $term->term_id, 'tse_abbreviation', true ) ) : '', + 'slug' => isset( $term->slug ) ? (string) $term->slug : '', + ) + ); + } + } + + return $this->normalize_venue_data( array() ); + } + + /** + * Get the timezone used for event schedule display. + * + * @return DateTimeZone + */ + private function get_event_timezone() { + return new DateTimeZone( 'America/Chicago' ); + } + + /** + * Build schedule data for the webhook template context. + * + * @param int $timestamp Event timestamp. + * @param string|null $gmt_iso Optional GMT ISO timestamp. + * @return array + */ + private function build_schedule_data( $timestamp, $gmt_iso = null ) { + $timestamp = (int) $timestamp; + if ( $timestamp <= 0 ) { + return array( + 'date' => '', + 'time' => '', + 'timezone' => '', + 'local_iso' => '', + 'local_display' => '', + 'gmt_iso' => '', + 'timestamp' => 0, + ); + } + + $timezone = $this->get_event_timezone(); + return array( - 'id' => isset( $venue->term_id ) ? (int) $venue->term_id : 0, - 'name' => isset( $venue->name ) ? (string) $venue->name : '', - 'slug' => isset( $venue->slug ) ? (string) $venue->slug : '', + 'date' => wp_date( 'Y-m-d', $timestamp, $timezone ), + 'time' => wp_date( 'g:i A', $timestamp, $timezone ), + 'timezone' => wp_date( 'T', $timestamp, $timezone ), + 'local_iso' => wp_date( DATE_ATOM, $timestamp, $timezone ), + 'local_display' => wp_date( 'Y-m-d g:i A T', $timestamp, $timezone ), + 'gmt_iso' => is_string( $gmt_iso ) && '' !== $gmt_iso ? $gmt_iso : gmdate( DATE_ATOM, $timestamp ), + 'timestamp' => $timestamp, ); } } diff --git a/tests/test-sp-webhooks.php b/tests/test-sp-webhooks.php index 22078c5..4baecca 100644 --- a/tests/test-sp-webhooks.php +++ b/tests/test-sp-webhooks.php @@ -39,6 +39,331 @@ class Test_SP_Webhooks extends WP_UnitTestCase { $this->assertStringContainsString( '"id":55', $rendered ); } + /** + * Venue aliases and split schedule fields should render from the context. + */ + public function test_render_template_supports_field_alias_and_schedule_parts() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $template = 'Field={{ event.field.short_name }} Venue={{ event.venue.abbreviation }} Time={{ event.scheduled.time }} {{ event.scheduled.timezone }}'; + $context = array( + 'event' => array( + 'field' => array( + 'short_name' => 'North', + ), + 'venue' => array( + 'abbreviation' => 'NF', + ), + 'scheduled' => array( + 'time' => '7:30 PM', + 'timezone' => 'CDT', + ), + ), + ); + + $rendered = $webhooks->render_template( $template, $context ); + + $this->assertSame( 'Field=North Venue=NF Time=7:30 PM CDT', $rendered ); + } + + /** + * Event context should expose home and away team aliases. + */ + public function test_render_template_supports_event_team_aliases() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $template = 'Home={{ event.home_team.name }} Away={{ event.away_team.name }}'; + $context = array( + 'event' => array( + 'home_team' => array( + 'name' => 'Home Team', + ), + 'away_team' => array( + 'name' => 'Away Team', + ), + ), + ); + + $rendered = $webhooks->render_template( $template, $context ); + + $this->assertSame( 'Home=Home Team Away=Away Team', $rendered ); + } + + /** + * Event context should expose the current SportsPress schedule status. + */ + public function test_render_template_supports_event_status_alias() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $template = 'Status={{ event.status }}'; + $context = array( + 'event' => array( + 'status' => 'Postponed', + ), + ); + + $rendered = $webhooks->render_template( $template, $context ); + + $this->assertSame( 'Status=Postponed', $rendered ); + } + + /** + * Date filter should accept PHP date format strings for schedule values. + */ + public function test_render_template_supports_date_filter() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $template = 'Time={{ event.scheduled.timestamp|date("g:i A") }} ISO={{ event.scheduled.local_iso|date("m/d g:i A") }}'; + $context = array( + 'event' => array( + 'scheduled' => array( + 'timestamp' => 1714005000, + 'local_iso' => '2024-04-24T19:30:00-05:00', + ), + ), + ); + + $rendered = $webhooks->render_template( $template, $context ); + + $this->assertSame( 'Time=7:30 PM ISO=04/24 7:30 PM', $rendered ); + } + + /** + * Change notifications should expose before and after venue/time values. + */ + public function test_render_template_supports_before_after_venue_and_time() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $template = 'Venue {{ changes.previous.venue.name }} -> {{ changes.current.venue.name }} Time {{ changes.previous.time }} -> {{ changes.current.time }}'; + $context = array( + 'changes' => array( + 'previous' => array( + 'time' => '6:00 PM', + 'venue' => array( + 'name' => 'North Field', + ), + ), + 'current' => array( + 'time' => '7:30 PM', + 'venue' => array( + 'name' => 'South Field', + ), + ), + ), + ); + + $rendered = $webhooks->render_template( $template, $context ); + + $this->assertSame( 'Venue North Field -> South Field Time 6:00 PM -> 7:30 PM', $rendered ); + } + + /** + * Change notifications should expose before and after home/away team values. + */ + public function test_render_template_supports_before_after_teams() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $template = 'Home {{ changes.previous.home_team.name }} -> {{ changes.current.home_team.name }} Away {{ changes.previous.away_team.name }} -> {{ changes.current.away_team.name }}'; + $context = array( + 'changes' => array( + 'previous' => array( + 'home_team' => array( + 'name' => 'Old Home', + ), + 'away_team' => array( + 'name' => 'Old Away', + ), + ), + 'current' => array( + 'home_team' => array( + 'name' => 'New Home', + ), + 'away_team' => array( + 'name' => 'New Away', + ), + ), + ), + ); + + $rendered = $webhooks->render_template( $template, $context ); + + $this->assertSame( 'Home Old Home -> New Home Away Old Away -> New Away', $rendered ); + } + + /** + * Change notifications should expose before and after status values. + */ + public function test_render_template_supports_before_after_status() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $template = 'Status {{ changes.previous.status }} -> {{ changes.current.status }}'; + $context = array( + 'changes' => array( + 'previous' => array( + 'status' => 'On time', + ), + 'current' => array( + 'status' => 'Postponed', + ), + ), + ); + + $rendered = $webhooks->render_template( $template, $context ); + + $this->assertSame( 'Status On time -> Postponed', $rendered ); + } + + /** + * Schedule snapshots should treat status changes as meaningful changes. + */ + public function test_schedule_snapshot_signature_changes_when_status_changes() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $method = new ReflectionMethod( $webhooks, 'schedule_snapshots_match' ); + $method->setAccessible( true ); + + $left = array( + 'local_iso' => '2026-05-02T10:30:00-05:00', + 'gmt_iso' => '2026-05-02T15:30:00+00:00', + 'status' => 'On time', + 'venue' => array( + 'name' => 'Winnemac Park', + ), + 'teams' => array( + array( 'name' => 'Hawks' ), + array( 'name' => 'Electrons' ), + ), + ); + $right = $left; + $right['status'] = 'Canceled'; + + $this->assertFalse( $method->invoke( $webhooks, $left, $right ) ); + } + + /** + * Status snapshots should expose a display label and keep the raw SportsPress key. + */ + public function test_build_change_snapshot_normalizes_status_label_and_slug() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $method = new ReflectionMethod( $webhooks, 'build_change_snapshot' ); + $method->setAccessible( true ); + + $snapshot = $method->invoke( $webhooks, array(), array(), array(), 'cancelled' ); + + $this->assertSame( 'Canceled', $snapshot['status'] ); + $this->assertSame( 'cancelled', $snapshot['sp_status'] ); + } + + /** + * Conditionals should support simple comparisons and else branches. + */ + public function test_render_template_supports_conditionals() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $template = "{% if changes.previous.time != changes.current.time %}Time changed: {{ changes.previous.time }} -> {{ changes.current.time }}\n{% endif %}{% if changes.previous.field.name == changes.current.field.name %}Field unchanged{% else %}Field changed: {{ changes.previous.field.name }} -> {{ changes.current.field.name }}{% endif %}"; + $context = array( + 'changes' => array( + 'previous' => array( + 'time' => '6:00 PM', + 'field' => array( + 'name' => 'North Field', + ), + ), + 'current' => array( + 'time' => '7:30 PM', + 'field' => array( + 'name' => 'South Field', + ), + ), + ), + ); + + $rendered = $webhooks->render_template( $template, $context ); + + $this->assertSame( "Time changed: 6:00 PM -> 7:30 PM\nField changed: North Field -> South Field", $rendered ); + } + + /** + * Truthy conditionals should render when the referenced value exists. + */ + public function test_render_template_supports_truthy_conditionals() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $template = '{% if changes.current.home_team.name %}Home: {{ changes.current.home_team.name }}{% endif %}'; + $context = array( + 'changes' => array( + 'current' => array( + 'home_team' => array( + 'name' => 'Home Team', + ), + ), + ), + ); + + $rendered = $webhooks->render_template( $template, $context ); + + $this->assertSame( 'Home: Home Team', $rendered ); + } + + /** + * Team change conditionals should stay false when only schedule fields changed. + */ + public function test_team_conditionals_do_not_fire_for_schedule_only_changes() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $template = '{% if changes.previous.away_team.name != changes.current.away_team.name %}Away changed{% else %}Away unchanged{% endif %}'; + $context = array( + 'changes' => array( + 'previous' => array( + 'time' => '6:00 PM', + 'away_team' => array( + 'name' => 'Away Team', + ), + ), + 'current' => array( + 'time' => '7:30 PM', + 'away_team' => array( + 'name' => 'Away Team', + ), + ), + ), + ); + + $rendered = $webhooks->render_template( $template, $context ); + + $this->assertSame( 'Away unchanged', $rendered ); + } + + /** + * Conditionals should support simple or expressions with else branches. + */ + public function test_render_template_supports_or_conditionals() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $template = '{% if event.status == "Postponed" or event.status == "Canceled" %}Delayed{% else %}Normal{% endif %}'; + $context = array( + 'event' => array( + 'status' => 'Canceled', + ), + ); + + $rendered = $webhooks->render_template( $template, $context ); + + $this->assertSame( 'Delayed', $rendered ); + } + + /** + * Test webhook AJAX should honor the submitted row index. + */ + public function test_get_submitted_test_webhook_row_uses_matching_index() { + $webhooks = Tony_Sportspress_Webhooks::instance(); + $method = new ReflectionMethod( $webhooks, 'get_submitted_test_webhook_row' ); + $method->setAccessible( true ); + + $result = $method->invoke( + $webhooks, + array( + '2' => array( + 'name' => 'Second Row', + ), + ), + array( + '2' => '123', + ) + ); + + $this->assertSame( 'Second Row', $result['row']['name'] ); + $this->assertSame( 123, $result['event_id'] ); + } + /** * Sanitization should keep only complete provider-specific webhook rows. */