From bfc74fcab6c432a320355aba7ef6e1112c6f97b2 Mon Sep 17 00:00:00 2001 From: Anthony Correa Date: Wed, 1 Apr 2026 18:20:56 -0500 Subject: [PATCH] Refactor schedule exports into feed builders --- includes/sp-event-csv.php | 250 +------- includes/sp-event-export.php | 922 ++++++++++++++++++++++++++++ includes/sp-printable-calendars.php | 291 +++++++-- includes/sp-schedule-exporter.php | 746 ++++++++++++++-------- includes/sp-url-builder.php | 285 +++++++++ tonys-sportspress-enhancements.php | 2 + 6 files changed, 1962 insertions(+), 534 deletions(-) create mode 100644 includes/sp-event-export.php create mode 100644 includes/sp-url-builder.php diff --git a/includes/sp-event-csv.php b/includes/sp-event-csv.php index 079aaff..18cd402 100644 --- a/includes/sp-event-csv.php +++ b/includes/sp-event-csv.php @@ -10,256 +10,8 @@ if ( ! defined( 'ABSPATH' ) ) { } /** - * Register the SportsPress calendar CSV feed endpoint. - * - * @return void + * SportsPress event CSV importer tools. */ -function tse_sp_register_calendar_csv_feed() { - add_feed( 'sp-csv', 'tse_sp_render_calendar_csv_feed' ); -} -add_action( 'init', 'tse_sp_register_calendar_csv_feed', 20 ); - -/** - * Replace the stock SportsPress calendar feeds metabox with one that includes CSV. - * - * @return void - */ -function tse_sp_replace_calendar_feeds_metabox() { - remove_meta_box( 'sp_feedsdiv', 'sp_calendar', 'side' ); - add_meta_box( - 'sp_feedsdiv', - esc_attr__( 'Feeds', 'sportspress' ), - 'tse_sp_render_calendar_feeds_metabox', - 'sp_calendar', - 'side', - 'default' - ); -} -add_action( 'add_meta_boxes_sp_calendar', 'tse_sp_replace_calendar_feeds_metabox', 40 ); - -/** - * Return the CSV feed format definition used in the metabox. - * - * @return array - */ -function tse_sp_get_calendar_csv_feed_formats() { - return array( - 'download' => array( - 'name' => __( 'CSV Download', 'tonys-sportspress-enhancements' ), - ), - ); -} - -/** - * Render the calendar feeds metabox with CSV support. - * - * @param WP_Post $post Current calendar post. - * @return void - */ -function tse_sp_render_calendar_feeds_metabox( $post ) { - $feeds = new SP_Feeds(); - $calendar_feeds = is_array( $feeds->calendar ) ? $feeds->calendar : array(); - $calendar_feeds['csv'] = tse_sp_get_calendar_csv_feed_formats(); - ?> -
- $formats ) : ?> - ID ); - } else { - $link = add_query_arg( 'feed', 'sp-' . $slug, untrailingslashit( get_post_permalink( $post ) ) ); - } - ?> - - -

- - -

-

- -

- -

- -

- - - -
- post_type ) { - return $post; - } - - $queried_object = get_queried_object(); - - if ( $queried_object instanceof WP_Post && 'sp_calendar' === $queried_object->post_type ) { - return $queried_object; - } - - $calendar_id = get_queried_object_id(); - if ( $calendar_id && 'sp_calendar' === get_post_type( $calendar_id ) ) { - return get_post( $calendar_id ); - } - - return null; -} - -/** - * Return the home and away teams for an event in stored order. - * - * @param int $event_id SportsPress event ID. - * @return array - */ -function tse_sp_get_event_home_away_teams( $event_id ) { - $teams = array_values( array_filter( array_map( 'absint', get_post_meta( $event_id, 'sp_team', false ) ) ) ); - - return array( - 'home' => isset( $teams[0] ) ? get_the_title( $teams[0] ) : '', - 'away' => isset( $teams[1] ) ? get_the_title( $teams[1] ) : '', - ); -} - -/** - * Return the field name(s) for an event. - * - * @param int $event_id SportsPress event ID. - * @return string - */ -function tse_sp_get_event_field_name( $event_id ) { - $venues = get_the_terms( $event_id, 'sp_venue' ); - - if ( empty( $venues ) || is_wp_error( $venues ) ) { - return ''; - } - - return implode( ', ', wp_list_pluck( $venues, 'name' ) ); -} - -/** - * Render the SportsPress calendar CSV feed. - * - * @return void - */ -function tse_sp_render_calendar_csv_feed() { - if ( ! class_exists( 'SP_Calendar' ) ) { - wp_die( esc_html__( 'ERROR: SportsPress is required for this feed.', 'tonys-sportspress-enhancements' ), '', array( 'response' => 500 ) ); - } - - $calendar = tse_sp_get_calendar_csv_post(); - - if ( ! $calendar ) { - wp_die( esc_html__( 'ERROR: This is not a valid calendar feed.', 'tonys-sportspress-enhancements' ), '', array( 'response' => 404 ) ); - } - - $team_id = isset( $_GET['team_id'] ) ? absint( wp_unslash( $_GET['team_id'] ) ) : 0; - - $calendar_data = new SP_Calendar( $calendar ); - if ( $team_id ) { - $calendar_data->team = $team_id; - } - - $events = (array) $calendar_data->data(); - - $filename = sanitize_title( $calendar->post_name ? $calendar->post_name : $calendar->post_title ); - if ( '' === $filename ) { - $filename = 'schedule'; - } - if ( $team_id ) { - $filename .= '-team-' . $team_id; - } - - header( 'Content-Type: text/csv; charset=utf-8' ); - header( 'Content-Disposition: inline; filename=' . $filename . '.csv' ); - - $output = fopen( 'php://output', 'w' ); - - if ( false === $output ) { - wp_die( esc_html__( 'ERROR: Unable to generate the CSV feed.', 'tonys-sportspress-enhancements' ), '', array( 'response' => 500 ) ); - } - - // Excel expects a BOM for UTF-8 CSV files. - fwrite( $output, "\xEF\xBB\xBF" ); - - fputcsv( - $output, - array( - 'Date', - 'Time', - 'Away Team', - 'Home Team', - 'Field Name', - ) - ); - - foreach ( $events as $event ) { - $teams = tse_sp_get_event_home_away_teams( $event->ID ); - - fputcsv( - $output, - array( - sp_get_date( $event ), - sp_get_time( $event ), - $teams['away'], - $teams['home'], - tse_sp_get_event_field_name( $event->ID ), - ) - ); - } - - fclose( $output ); - exit; -} /** * CSV headers recognized by this importer. diff --git a/includes/sp-event-export.php b/includes/sp-event-export.php new file mode 100644 index 0000000..2fab4a3 --- /dev/null +++ b/includes/sp-event-export.php @@ -0,0 +1,922 @@ + array( + 'label' => __( 'Matchup', 'tonys-sportspress-enhancements' ), + 'description' => __( 'Date, time, away team, home team, and field columns.', 'tonys-sportspress-enhancements' ), + ), + 'team' => array( + 'label' => __( 'Team', 'tonys-sportspress-enhancements' ), + 'description' => __( 'Team-centric schedule rows with opponent and home/away columns.', 'tonys-sportspress-enhancements' ), + ), + ); +} + +/** + * Get available export columns grouped by format. + * + * @return array + */ +function tse_sp_event_export_get_column_definitions() { + return array( + 'matchup' => array( + 'date' => __( 'Date', 'tonys-sportspress-enhancements' ), + 'time' => __( 'Time', 'tonys-sportspress-enhancements' ), + 'season' => __( 'Season', 'tonys-sportspress-enhancements' ), + 'league' => __( 'League', 'tonys-sportspress-enhancements' ), + 'away_team' => __( 'Away Team', 'tonys-sportspress-enhancements' ), + 'home_team' => __( 'Home Team', 'tonys-sportspress-enhancements' ), + 'field_name' => __( 'Field Name', 'tonys-sportspress-enhancements' ), + 'officials' => __( 'Officials', 'tonys-sportspress-enhancements' ), + ), + 'team' => array( + 'label' => __( 'Extra Label', 'tonys-sportspress-enhancements' ), + 'date' => __( 'Date', 'tonys-sportspress-enhancements' ), + 'time' => __( 'Time', 'tonys-sportspress-enhancements' ), + 'season' => __( 'Season', 'tonys-sportspress-enhancements' ), + 'league' => __( 'League', 'tonys-sportspress-enhancements' ), + 'team_name' => __( 'Team', 'tonys-sportspress-enhancements' ), + 'opponent_name' => __( 'Opponent', 'tonys-sportspress-enhancements' ), + 'location_flag' => __( 'Home/Away', 'tonys-sportspress-enhancements' ), + 'field_name' => __( 'Field Name', 'tonys-sportspress-enhancements' ), + 'field_abbreviation' => __( 'Field Abbreviation', 'tonys-sportspress-enhancements' ), + 'field_short_name' => __( 'Field Short Name', 'tonys-sportspress-enhancements' ), + 'officials' => __( 'Officials', 'tonys-sportspress-enhancements' ), + 'home_team' => __( 'Home Team', 'tonys-sportspress-enhancements' ), + 'away_team' => __( 'Away Team', 'tonys-sportspress-enhancements' ), + ), + ); +} + +/** + * Get default columns for an export format. + * + * @param string $format Export format. + * @return array + */ +function tse_sp_event_export_get_default_columns( $format ) { + $defaults = array( + 'matchup' => array( 'date', 'time', 'season', 'league', 'away_team', 'home_team', 'field_name' ), + 'team' => array( 'label', 'date', 'time', 'season', 'league', 'opponent_name', 'location_flag', 'field_name' ), + ); + + return isset( $defaults[ $format ] ) ? $defaults[ $format ] : $defaults['matchup']; +} + +/** + * Sanitize an export format. + * + * @param string $format Raw format. + * @return string + */ +function tse_sp_event_export_sanitize_format( $format ) { + $format = sanitize_key( (string) $format ); + $formats = tse_sp_event_export_get_formats(); + + return isset( $formats[ $format ] ) ? $format : 'matchup'; +} + +/** + * Sanitize requested columns for an export format. + * + * @param string $format Export format. + * @param string|array $columns Requested columns. + * @return array + */ +function tse_sp_event_export_sanitize_columns( $format, $columns ) { + $definitions = tse_sp_event_export_get_column_definitions(); + $available = isset( $definitions[ $format ] ) ? $definitions[ $format ] : array(); + + if ( ! is_array( $columns ) ) { + $columns = explode( ',', (string) $columns ); + } + + $sanitized = array(); + + foreach ( $columns as $column ) { + $key = sanitize_key( (string) $column ); + if ( '' === $key || ! isset( $available[ $key ] ) || in_array( $key, $sanitized, true ) ) { + continue; + } + + $sanitized[] = $key; + } + + if ( empty( $sanitized ) ) { + return tse_sp_event_export_get_default_columns( $format ); + } + + return $sanitized; +} + +/** + * Normalize one or more numeric IDs from a request value. + * + * Accepts scalars, arrays, and comma-delimited strings. + * + * @param mixed $value Raw request value. + * @return int[] + */ +function tse_sp_event_export_parse_id_list( $value ) { + if ( is_array( $value ) ) { + $raw_values = $value; + } else { + $raw_values = explode( ',', (string) $value ); + } + + $ids = array(); + + foreach ( $raw_values as $raw_value ) { + $id = absint( trim( (string) $raw_value ) ); + if ( $id > 0 ) { + $ids[] = $id; + } + } + + $ids = array_values( array_unique( $ids ) ); + + return $ids; +} + +/** + * Normalize export request arguments. + * + * @param array|null $source Optional input source. + * @return array + */ +function tse_sp_event_export_normalize_request_args( $source = null ) { + $source = is_array( $source ) ? $source : $_GET; + $format = isset( $source['format'] ) ? tse_sp_event_export_sanitize_format( wp_unslash( $source['format'] ) ) : 'matchup'; + $team_ids = isset( $source['team_id'] ) ? tse_sp_event_export_parse_id_list( wp_unslash( $source['team_id'] ) ) : array(); + $season_ids = isset( $source['season_id'] ) ? tse_sp_event_export_parse_id_list( wp_unslash( $source['season_id'] ) ) : array(); + $league_ids = isset( $source['league_id'] ) ? tse_sp_event_export_parse_id_list( wp_unslash( $source['league_id'] ) ) : array(); + $field_ids = isset( $source['field_id'] ) ? tse_sp_event_export_parse_id_list( wp_unslash( $source['field_id'] ) ) : array(); + + return array( + 'team_id' => isset( $team_ids[0] ) ? $team_ids[0] : 0, + 'team_ids' => $team_ids, + 'season_id' => isset( $season_ids[0] ) ? $season_ids[0] : 0, + 'season_ids'=> $season_ids, + 'league_id' => isset( $league_ids[0] ) ? $league_ids[0] : 0, + 'league_ids'=> $league_ids, + 'field_id' => isset( $field_ids[0] ) ? $field_ids[0] : 0, + 'field_ids' => $field_ids, + 'format' => $format, + 'columns' => isset( $source['columns'] ) ? tse_sp_event_export_sanitize_columns( $format, wp_unslash( $source['columns'] ) ) : tse_sp_event_export_get_default_columns( $format ), + ); +} + +/** + * Validate export filters for the requested format. + * + * @param array $filters Export filters. + * @return void + */ +function tse_sp_event_export_validate_filters( $filters ) { + $format = tse_sp_event_export_sanitize_format( isset( $filters['format'] ) ? $filters['format'] : 'matchup' ); + $team_ids = isset( $filters['team_ids'] ) && is_array( $filters['team_ids'] ) ? $filters['team_ids'] : array(); + + if ( 'team' !== $format ) { + return; + } + + if ( empty( $team_ids ) ) { + wp_die( esc_html__( 'Team format requires a team filter.', 'tonys-sportspress-enhancements' ), '', array( 'response' => 400 ) ); + } + + if ( count( $team_ids ) > 1 ) { + wp_die( esc_html__( 'Team format does not support multiple teams.', 'tonys-sportspress-enhancements' ), '', array( 'response' => 400 ) ); + } +} + +/** + * Query matching event posts for export. + * + * @param array $filters Export filters. + * @return WP_Post[] + */ +function tse_sp_event_export_query_posts( $filters ) { + $team_ids = isset( $filters['team_ids'] ) && is_array( $filters['team_ids'] ) ? array_values( array_filter( array_map( 'absint', $filters['team_ids'] ) ) ) : array(); + $season_ids = isset( $filters['season_ids'] ) && is_array( $filters['season_ids'] ) ? array_values( array_filter( array_map( 'absint', $filters['season_ids'] ) ) ) : array(); + $league_ids = isset( $filters['league_ids'] ) && is_array( $filters['league_ids'] ) ? array_values( array_filter( array_map( 'absint', $filters['league_ids'] ) ) ) : array(); + $field_ids = isset( $filters['field_ids'] ) && is_array( $filters['field_ids'] ) ? array_values( array_filter( array_map( 'absint', $filters['field_ids'] ) ) ) : array(); + + $args = array( + 'post_type' => 'sp_event', + 'post_status' => array( 'publish', 'future' ), + 'posts_per_page' => -1, + 'orderby' => 'date', + 'order' => 'ASC', + 'no_found_rows' => true, + ); + + if ( ! empty( $team_ids ) ) { + $args['meta_query'] = array( + array( + 'key' => 'sp_team', + 'value' => array_map( 'strval', $team_ids ), + 'compare' => 'IN', + ), + ); + } + + $tax_query = array(); + + if ( ! empty( $season_ids ) ) { + $tax_query[] = array( + 'taxonomy' => 'sp_season', + 'field' => 'term_id', + 'terms' => $season_ids, + ); + } + + if ( ! empty( $league_ids ) ) { + $tax_query[] = array( + 'taxonomy' => 'sp_league', + 'field' => 'term_id', + 'terms' => $league_ids, + ); + } + + if ( ! empty( $field_ids ) ) { + $tax_query[] = array( + 'taxonomy' => 'sp_venue', + 'field' => 'term_id', + 'terms' => $field_ids, + ); + } + + if ( ! empty( $tax_query ) ) { + if ( count( $tax_query ) > 1 ) { + $tax_query['relation'] = 'AND'; + } + + $args['tax_query'] = $tax_query; + } + + $query = new WP_Query( $args ); + + return is_array( $query->posts ) ? $query->posts : array(); +} + +/** + * Query matching schedule events for export. + * + * @param array $filters Export filters. + * @return array + */ +function tse_sp_event_export_get_events( $filters ) { + $team_id = isset( $filters['team_id'] ) ? absint( $filters['team_id'] ) : 0; + $selected_ids = isset( $filters['team_ids'] ) && is_array( $filters['team_ids'] ) ? array_values( array_filter( array_map( 'absint', $filters['team_ids'] ) ) ) : array(); + $query_posts = tse_sp_event_export_query_posts( $filters ); + $events = array(); + $team_name = $team_id > 0 ? get_the_title( $team_id ) : ''; + + foreach ( $query_posts as $event ) { + $event_id = $event instanceof WP_Post ? (int) $event->ID : 0; + if ( $event_id <= 0 ) { + continue; + } + + $teams = array_values( array_unique( array_map( 'intval', get_post_meta( $event_id, 'sp_team', false ) ) ) ); + if ( ! empty( $selected_ids ) && empty( array_intersect( $selected_ids, $teams ) ) ) { + continue; + } + + $home_id = isset( $teams[0] ) ? (int) $teams[0] : 0; + $away_id = isset( $teams[1] ) ? (int) $teams[1] : 0; + $venue = tse_sp_event_export_get_primary_field( $event_id ); + + if ( $team_id > 0 ) { + $location_flag = $home_id === $team_id ? 'Home' : 'Away'; + $opponent_id = $home_id === $team_id ? $away_id : $home_id; + } else { + $location_flag = ''; + $opponent_id = 0; + } + + $events[] = array( + 'event_id' => $event_id, + 'label' => '', + 'date' => get_post_time( 'm/d/Y', false, $event_id, true ), + 'time' => strtoupper( (string) ( function_exists( 'sp_get_time' ) ? sp_get_time( $event_id ) : get_post_time( get_option( 'time_format' ), false, $event_id, true ) ) ), + 'team_name' => is_string( $team_name ) ? $team_name : '', + 'opponent_name' => $opponent_id > 0 ? get_the_title( $opponent_id ) : '', + 'location_flag' => $location_flag, + 'home_team' => $home_id > 0 ? get_the_title( $home_id ) : '', + 'away_team' => $away_id > 0 ? get_the_title( $away_id ) : '', + 'field_name' => isset( $venue['name'] ) ? $venue['name'] : '', + 'field_abbreviation' => isset( $venue['abbreviation'] ) ? $venue['abbreviation'] : '', + 'field_short_name' => isset( $venue['short_name'] ) ? $venue['short_name'] : '', + 'season' => tse_sp_event_export_get_event_term_names( $event_id, 'sp_season' ), + 'league' => tse_sp_event_export_get_event_term_names( $event_id, 'sp_league' ), + 'officials' => tse_sp_event_export_get_officials_value( $event_id ), + ); + } + + foreach ( $events as $index => $event ) { + $events[ $index ]['label'] = sprintf( 'G#%02d', $index + 1 ); + } + + wp_reset_postdata(); + + return $events; +} + +/** + * Get event term names as a semicolon-delimited string. + * + * @param int $event_id Event ID. + * @param string $taxonomy Taxonomy name. + * @return string + */ +function tse_sp_event_export_get_event_term_names( $event_id, $taxonomy ) { + $terms = get_the_terms( $event_id, $taxonomy ); + + if ( ! is_array( $terms ) || empty( $terms ) ) { + return ''; + } + + $names = array_values( array_filter( array_map( 'strval', wp_list_pluck( $terms, 'name' ) ) ) ); + + return implode( '; ', array_unique( $names ) ); +} + +/** + * Get primary field metadata for an event. + * + * @param int $event_id Event ID. + * @return array + */ +function tse_sp_event_export_get_primary_field( $event_id ) { + $venues = get_the_terms( $event_id, 'sp_venue' ); + + if ( ! is_array( $venues ) || ! isset( $venues[0] ) || ! $venues[0] instanceof WP_Term ) { + return array( + 'name' => '', + 'abbreviation' => '', + 'short_name' => '', + ); + } + + $venue = $venues[0]; + + return array( + 'name' => isset( $venue->name ) ? (string) $venue->name : '', + 'abbreviation' => trim( (string) get_term_meta( $venue->term_id, 'tse_abbreviation', true ) ), + 'short_name' => trim( (string) get_term_meta( $venue->term_id, 'tse_short_name', true ) ), + ); +} + +/** + * Get event officials as a semicolon-delimited string. + * + * @param int $event_id Event ID. + * @return string + */ +function tse_sp_event_export_get_officials_value( $event_id ) { + $official_groups = get_post_meta( $event_id, 'sp_officials', true ); + + if ( ! is_array( $official_groups ) || empty( $official_groups ) ) { + return ''; + } + + $official_names = array(); + + foreach ( $official_groups as $official_ids ) { + if ( ! is_array( $official_ids ) ) { + continue; + } + + foreach ( $official_ids as $official_id ) { + $official_id = absint( $official_id ); + if ( $official_id <= 0 || 'sp_official' !== get_post_type( $official_id ) ) { + continue; + } + + $name = get_the_title( $official_id ); + if ( '' === $name ) { + continue; + } + + $official_names[ $official_id ] = $name; + } + } + + if ( empty( $official_names ) ) { + return ''; + } + + return implode( '; ', array_values( $official_names ) ); +} + +/** + * Escape iCalendar text content. + * + * @param string $value Raw value. + * @return string + */ +function tse_sp_event_export_escape_ical_text( $value ) { + $value = html_entity_decode( wp_strip_all_tags( (string) $value ), ENT_QUOTES, get_bloginfo( 'charset' ) ); + $value = str_replace( array( '\\', "\r\n", "\r", "\n", ',', ';' ), array( '\\\\', '\n', '\n', '\n', '\,', '\;' ), $value ); + + return $value; +} + +/** + * Fold an iCalendar content line. + * + * @param string $line Raw line. + * @return string + */ +function tse_sp_event_export_fold_ical_line( $line ) { + return wordwrap( (string) $line, 60, "\r\n\t", true ); +} + +/** + * Get the ICS summary for an event. + * + * For matchup format, this mirrors the SportsPress result/title behavior. + * For team format, this becomes a team-centric opponent summary. + * + * @param WP_Post $event Event post. + * @param array $filters Export filters. + * @return string + */ +function tse_sp_event_export_get_ical_summary( $event, $filters ) { + $format = tse_sp_event_export_sanitize_format( isset( $filters['format'] ) ? $filters['format'] : 'matchup' ); + $team_ids = isset( $filters['team_ids'] ) && is_array( $filters['team_ids'] ) ? array_values( array_filter( array_map( 'absint', $filters['team_ids'] ) ) ) : array(); + $team_id = isset( $team_ids[0] ) ? $team_ids[0] : 0; + + if ( 'team' === $format && $team_id > 0 ) { + $teams = array_values( array_unique( array_map( 'intval', get_post_meta( $event->ID, 'sp_team', false ) ) ) ); + $home_id = isset( $teams[0] ) ? (int) $teams[0] : 0; + $away_id = isset( $teams[1] ) ? (int) $teams[1] : 0; + + if ( in_array( $team_id, $teams, true ) ) { + $is_home = $home_id === $team_id; + $opponent_id = $is_home ? $away_id : $home_id; + $opponent = $opponent_id > 0 ? get_the_title( $opponent_id ) : __( 'TBD', 'tonys-sportspress-enhancements' ); + $summary = sprintf( + /* translators: 1: preposition, 2: opponent name. */ + __( '%1$s %2$s', 'tonys-sportspress-enhancements' ), + $is_home ? 'vs' : 'at', + $opponent + ); + + return apply_filters( 'sportspress_ical_feed_summary', $summary, $event ); + } + } + + $main_result = get_option( 'sportspress_primary_result', null ); + $results = array(); + $teams = (array) get_post_meta( $event->ID, 'sp_team', false ); + $teams = array_filter( array_unique( $teams ) ); + + if ( ! empty( $teams ) ) { + $event_results = get_post_meta( $event->ID, 'sp_results', true ); + + foreach ( $teams as $team_id ) { + $team_id = absint( $team_id ); + if ( $team_id <= 0 ) { + continue; + } + + $team = get_post( $team_id ); + if ( ! $team instanceof WP_Post ) { + continue; + } + + $team_results = is_array( $event_results ) && isset( $event_results[ $team_id ] ) ? $event_results[ $team_id ] : null; + + if ( $main_result ) { + $team_result = is_array( $team_results ) && isset( $team_results[ $main_result ] ) ? $team_results[ $main_result ] : null; + } else { + if ( is_array( $team_results ) ) { + end( $team_results ); + $team_result = prev( $team_results ); + } else { + $team_result = null; + } + } + + if ( null !== $team_result && '' !== (string) $team_result ) { + $results[] = get_the_title( $team_id ) . ' ' . $team_result; + } + } + } + + $summary = ! empty( $results ) ? implode( ' ', $results ) : $event->post_title; + + $summary = preg_replace_callback( + '/(&#[0-9]+;)/', + static function( $matches ) { + return mb_convert_encoding( $matches[1], 'UTF-8', 'HTML-ENTITIES' ); + }, + $summary + ); + + return apply_filters( 'sportspress_ical_feed_summary', $summary, $event ); +} + +/** + * Get the ICS location payload for an event. + * + * @param WP_Post $event Event post. + * @return array + */ +function tse_sp_event_export_get_ical_location_data( $event ) { + $location = ''; + $geo = false; + $venues = get_the_terms( $event->ID, 'sp_venue' ); + + if ( ! is_array( $venues ) || empty( $venues ) ) { + return array( + 'location' => $location, + 'geo' => $geo, + ); + } + + $venue = reset( $venues ); + $location = $venue->name; + $meta = get_option( 'taxonomy_' . $venue->term_id ); + $address = is_array( $meta ) && isset( $meta['sp_address'] ) ? $meta['sp_address'] : false; + + if ( false !== $address && '' !== (string) $address ) { + $location = $venue->name . ', ' . $address; + } + + $latitude = is_array( $meta ) && isset( $meta['sp_latitude'] ) ? $meta['sp_latitude'] : false; + $longitude = is_array( $meta ) && isset( $meta['sp_longitude'] ) ? $meta['sp_longitude'] : false; + + if ( false !== $latitude && false !== $longitude && '' !== (string) $latitude && '' !== (string) $longitude ) { + $geo = $latitude . ';' . $longitude; + } + + return array( + 'location' => (string) $location, + 'geo' => $geo, + ); +} + +/** + * Build iCalendar output for the requested filters. + * + * @param array $filters Export filters. + * @return string + */ +function tse_sp_event_export_build_ical_output( $filters ) { + $query_posts = tse_sp_event_export_query_posts( $filters ); + $locale = substr( get_locale(), 0, 2 ); + $timezone = sanitize_option( 'timezone_string', get_option( 'timezone_string' ) ); + $url = tse_sp_event_export_get_feed_url( $filters, 'ics' ); + $calendar = tse_sp_event_export_get_feed_title( $filters ); + $output = +"BEGIN:VCALENDAR\r\n" . +"VERSION:2.0\r\n" . +'PRODID:-//ThemeBoy//SportsPress//' . strtoupper( $locale ) . "\r\n" . +"CALSCALE:GREGORIAN\r\n" . +"METHOD:PUBLISH\r\n" . +'URL:' . tse_sp_event_export_fold_ical_line( $url ) . "\r\n" . +'X-FROM-URL:' . tse_sp_event_export_fold_ical_line( $url ) . "\r\n" . +'NAME:' . tse_sp_event_export_escape_ical_text( $calendar ) . "\r\n" . +'X-WR-CALNAME:' . tse_sp_event_export_escape_ical_text( $calendar ) . "\r\n" . +'DESCRIPTION:' . tse_sp_event_export_escape_ical_text( $calendar ) . "\r\n" . +'X-WR-CALDESC:' . tse_sp_event_export_escape_ical_text( $calendar ) . "\r\n" . +"REFRESH-INTERVAL;VALUE=DURATION:PT2M\r\n" . +"X-PUBLISHED-TTL:PT2M\r\n" . +'TZID:' . $timezone . "\r\n" . +'X-WR-TIMEZONE:' . $timezone . "\r\n"; + + foreach ( $query_posts as $event ) { + if ( ! $event instanceof WP_Post ) { + continue; + } + + $date_format = 'Ymd\THis'; + $description = tse_sp_event_export_escape_ical_text( $event->post_content ); + $summary = tse_sp_event_export_get_ical_summary( $event, $filters ); + $minutes = get_post_meta( $event->ID, 'sp_minutes', true ); + $minutes = '' === $minutes ? get_option( 'sportspress_event_minutes', 90 ) : $minutes; + $end = new DateTime( $event->post_date ); + $end->add( new DateInterval( 'PT' . absint( $minutes ) . 'M' ) ); + $location = tse_sp_event_export_get_ical_location_data( $event ); + $event_url = get_permalink( $event ); + + $output .= "BEGIN:VEVENT\r\n"; + $output .= tse_sp_event_export_fold_ical_line( 'SUMMARY:' . tse_sp_event_export_escape_ical_text( $summary ) ) . "\r\n"; + $output .= 'UID:' . $event->ID . "\r\n"; + $output .= "STATUS:CONFIRMED\r\n"; + $output .= "DTSTAMP:19700101T000000\r\n"; + $output .= 'DTSTART:' . mysql2date( $date_format, $event->post_date ) . "\r\n"; + $output .= 'DTEND:' . $end->format( $date_format ) . "\r\n"; + $output .= 'LAST-MODIFIED:' . mysql2date( $date_format, $event->post_modified_gmt ) . "\r\n"; + + if ( '' !== $description ) { + $output .= tse_sp_event_export_fold_ical_line( 'DESCRIPTION:' . $description ) . "\r\n"; + } + + if ( '' !== $location['location'] ) { + $output .= tse_sp_event_export_fold_ical_line( 'LOCATION:' . tse_sp_event_export_escape_ical_text( $location['location'] ) ) . "\r\n"; + } + + if ( ! empty( $location['geo'] ) ) { + $output .= 'GEO:' . $location['geo'] . "\r\n"; + } + + if ( is_string( $event_url ) && '' !== $event_url ) { + $output .= tse_sp_event_export_fold_ical_line( 'URL:' . esc_url_raw( $event_url ) ) . "\r\n"; + } + + $output .= "END:VEVENT\r\n"; + } + + $output .= 'END:VCALENDAR'; + + return $output; +} + +/** + * Stream ICS output. + * + * @param array $filters Export filters. + * @param array $args Optional render args. + * @return void + */ +function tse_sp_event_export_stream_ical( $filters, $args = array() ) { + tse_sp_event_export_validate_filters( $filters ); + + $disposition = isset( $args['disposition'] ) ? sanitize_key( $args['disposition'] ) : 'inline'; + $disposition = in_array( $disposition, array( 'inline', 'attachment' ), true ) ? $disposition : 'inline'; + $output = tse_sp_event_export_build_ical_output( $filters ); + $filename = tse_sp_event_export_build_filename( $filters ) . '.ics'; + $etag = md5( $output ); + + header( 'Content-type: text/calendar; charset=utf-8' ); + header( 'Etag:' . '"' . $etag . '"' ); + header( 'Content-Disposition: ' . $disposition . '; filename=' . $filename ); + + echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + exit; +} + +/** + * Build a CSV row value for a logical column. + * + * @param array $event Normalized event row. + * @param string $column Column key. + * @return string + */ +function tse_sp_event_export_get_row_value( $event, $column ) { + return isset( $event[ $column ] ) ? (string) $event[ $column ] : ''; +} + +/** + * Build a CSV filename for the export. + * + * @param array $filters Export filters. + * @return string + */ +function tse_sp_event_export_build_filename( $filters ) { + $parts = array( 'schedule' ); + + $parts = array_merge( $parts, tse_sp_event_export_get_post_slugs( isset( $filters['team_ids'] ) ? $filters['team_ids'] : array() ) ); + $parts = array_merge( $parts, tse_sp_event_export_get_term_slugs_by_ids( isset( $filters['season_ids'] ) ? $filters['season_ids'] : array(), 'sp_season' ) ); + $parts = array_merge( $parts, tse_sp_event_export_get_term_slugs_by_ids( isset( $filters['league_ids'] ) ? $filters['league_ids'] : array(), 'sp_league' ) ); + $parts = array_merge( $parts, tse_sp_event_export_get_term_slugs_by_ids( isset( $filters['field_ids'] ) ? $filters['field_ids'] : array(), 'sp_venue' ) ); + + $parts[] = tse_sp_event_export_sanitize_format( isset( $filters['format'] ) ? $filters['format'] : 'matchup' ); + $parts = array_values( array_filter( $parts ) ); + + return implode( '-', $parts ); +} + +/** + * Build a human-readable feed title from filters. + * + * @param array $filters Export filters. + * @return string + */ +function tse_sp_event_export_get_feed_title( $filters ) { + $site_title = trim( (string) get_bloginfo( 'name' ) ); + $team_name = tse_sp_event_export_get_post_titles( isset( $filters['team_ids'] ) ? $filters['team_ids'] : array() ); + $season_name = tse_sp_event_export_get_term_names_by_ids( isset( $filters['season_ids'] ) ? $filters['season_ids'] : array(), 'sp_season' ); + $league_name = tse_sp_event_export_get_term_names_by_ids( isset( $filters['league_ids'] ) ? $filters['league_ids'] : array(), 'sp_league' ); + $field_name = tse_sp_event_export_get_term_names_by_ids( isset( $filters['field_ids'] ) ? $filters['field_ids'] : array(), 'sp_venue' ); + + $title = '' !== $site_title ? $site_title . ' ' . __( 'Event Feed', 'tonys-sportspress-enhancements' ) : __( 'Event Feed', 'tonys-sportspress-enhancements' ); + $details = array_filter( array( $team_name, $season_name, $league_name, $field_name ) ); + + if ( ! empty( $details ) ) { + $title .= ' (' . implode( ' • ', $details ) . ')'; + } + + return $title; +} + +/** + * Get term names for a list of term IDs. + * + * @param array $ids Term IDs. + * @param string $taxonomy Taxonomy name. + * @return string + */ +function tse_sp_event_export_get_term_names_by_ids( $ids, $taxonomy ) { + $names = array(); + + foreach ( (array) $ids as $id ) { + $term = get_term( absint( $id ), $taxonomy ); + if ( $term instanceof WP_Term ) { + $names[] = $term->name; + } + } + + $names = array_values( array_unique( array_filter( $names ) ) ); + + return implode( '; ', $names ); +} + +/** + * Get post titles for a list of post IDs. + * + * @param array $ids Post IDs. + * @return string + */ +function tse_sp_event_export_get_post_titles( $ids ) { + $titles = array(); + + foreach ( (array) $ids as $id ) { + $post = get_post( absint( $id ) ); + if ( $post instanceof WP_Post ) { + $titles[] = $post->post_title; + } + } + + $titles = array_values( array_unique( array_filter( $titles ) ) ); + + return implode( '; ', $titles ); +} + +/** + * Get term slugs for a list of term IDs. + * + * @param array $ids Term IDs. + * @param string $taxonomy Taxonomy name. + * @return string[] + */ +function tse_sp_event_export_get_term_slugs_by_ids( $ids, $taxonomy ) { + $slugs = array(); + + foreach ( (array) $ids as $id ) { + $term = get_term( absint( $id ), $taxonomy ); + if ( $term instanceof WP_Term && ! empty( $term->slug ) ) { + $slugs[] = sanitize_title( $term->slug ); + } + } + + return array_values( array_unique( array_filter( $slugs ) ) ); +} + +/** + * Get post slugs for a list of post IDs. + * + * @param array $ids Post IDs. + * @return string[] + */ +function tse_sp_event_export_get_post_slugs( $ids ) { + $slugs = array(); + + foreach ( (array) $ids as $id ) { + $post = get_post( absint( $id ) ); + if ( $post instanceof WP_Post ) { + $slugs[] = sanitize_title( $post->post_name ? $post->post_name : $post->post_title ); + } + } + + return array_values( array_unique( array_filter( $slugs ) ) ); +} + +/** + * Stream CSV output for the requested export. + * + * @param array $filters Export filters. + * @param array $args Optional render args. + * @return void + */ +function tse_sp_event_export_stream_csv( $filters, $args = array() ) { + $filters['format'] = tse_sp_event_export_sanitize_format( isset( $filters['format'] ) ? $filters['format'] : 'matchup' ); + $filters['columns'] = tse_sp_event_export_sanitize_columns( $filters['format'], isset( $filters['columns'] ) ? $filters['columns'] : array() ); + tse_sp_event_export_validate_filters( $filters ); + + $definitions = tse_sp_event_export_get_column_definitions(); + $headers = array(); + + foreach ( $filters['columns'] as $column ) { + $headers[] = $definitions[ $filters['format'] ][ $column ]; + } + + $events = tse_sp_event_export_get_events( $filters ); + $disposition = isset( $args['disposition'] ) ? sanitize_key( $args['disposition'] ) : 'inline'; + $disposition = in_array( $disposition, array( 'inline', 'attachment' ), true ) ? $disposition : 'inline'; + $filename = tse_sp_event_export_build_filename( $filters ) . '.csv'; + + header( 'Content-Type: text/csv; charset=utf-8' ); + header( 'Content-Disposition: ' . $disposition . '; filename=' . $filename ); + + $output = fopen( 'php://output', 'w' ); + + if ( false === $output ) { + wp_die( esc_html__( 'Unable to generate the CSV export.', 'tonys-sportspress-enhancements' ), '', array( 'response' => 500 ) ); + } + + fwrite( $output, "\xEF\xBB\xBF" ); + fputcsv( $output, $headers ); + + foreach ( $events as $event ) { + $row = array(); + + foreach ( $filters['columns'] as $column ) { + $row[] = tse_sp_event_export_get_row_value( $event, $column ); + } + + fputcsv( $output, $row ); + } + + fclose( $output ); + exit; +} + +/** + * Render the standalone CSV feed. + * + * @return void + */ +function tse_sp_event_export_render_feed() { + if ( ! class_exists( 'SP_Calendar' ) && ! post_type_exists( 'sp_event' ) ) { + wp_die( esc_html__( 'SportsPress is required for this feed.', 'tonys-sportspress-enhancements' ), '', array( 'response' => 500 ) ); + } + + tse_sp_event_export_stream_csv( tse_sp_event_export_normalize_request_args() ); +} + +/** + * Render the standalone ICS feed. + * + * @return void + */ +function tse_sp_event_export_render_ical_feed() { + if ( ! class_exists( 'SP_Calendar' ) && ! post_type_exists( 'sp_event' ) ) { + wp_die( esc_html__( 'SportsPress is required for this feed.', 'tonys-sportspress-enhancements' ), '', array( 'response' => 500 ) ); + } + + tse_sp_event_export_stream_ical( tse_sp_event_export_normalize_request_args() ); +} + +/** + * Build a shareable feed URL. + * + * @param array $args Export args. + * @param string $feed_type Feed type. + * @return string + */ +function tse_sp_event_export_get_feed_url( $args = array(), $feed_type = 'csv' ) { + $filters = tse_sp_event_export_normalize_request_args( $args ); + $feed = 'ics' === sanitize_key( $feed_type ) ? 'sp-ics' : 'sp-csv'; + $query = array( + 'feed' => $feed, + 'format' => $filters['format'], + 'team_id' => ! empty( $filters['team_ids'] ) ? implode( ',', $filters['team_ids'] ) : '', + 'season_id' => ! empty( $filters['season_ids'] ) ? implode( ',', $filters['season_ids'] ) : '', + 'league_id' => ! empty( $filters['league_ids'] ) ? implode( ',', $filters['league_ids'] ) : '', + 'field_id' => ! empty( $filters['field_ids'] ) ? implode( ',', $filters['field_ids'] ) : '', + 'columns' => implode( ',', $filters['columns'] ), + ); + + return add_query_arg( array_filter( $query, 'strlen' ), home_url( '/' ) ); +} diff --git a/includes/sp-printable-calendars.php b/includes/sp-printable-calendars.php index db4eecb..a0dc180 100644 --- a/includes/sp-printable-calendars.php +++ b/includes/sp-printable-calendars.php @@ -244,6 +244,73 @@ if ( ! class_exists( 'Tony_Sportspress_Printable_Calendars' ) ) { } $current_tab = $this->current_settings_tab(); + + echo '
'; + echo '

' . esc_html__( 'Tony\'s Settings', 'tonys-sportspress-enhancements' ) . '

'; + $this->render_settings_tabs( $current_tab ); + + if ( self::TAB_PRINTABLE === $current_tab ) { + $this->render_printable_settings_tab( $current_tab ); + } else { + do_action( 'tse_tonys_settings_render_tab_' . $current_tab ); + } + + echo '
'; + } + + /** + * Render Tony's settings tabs. + * + * @param string $current_tab Current tab key. + */ + private function render_settings_tabs( $current_tab ) { + $tabs = apply_filters( + 'tse_tonys_settings_tabs', + array( + self::TAB_PRINTABLE => __( 'Printable Calendars', 'tonys-sportspress-enhancements' ), + ) + ); + + echo ''; + } + + /** + * Resolve the current settings tab. + * + * @return string + */ + private function current_settings_tab() { + $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : self::TAB_PRINTABLE; + $tabs = apply_filters( + 'tse_tonys_settings_tabs', + array( + self::TAB_PRINTABLE => __( 'Printable Calendars', 'tonys-sportspress-enhancements' ), + ) + ); + + return isset( $tabs[ $tab ] ) ? $tab : self::TAB_PRINTABLE; + } + + /** + * Render printable settings tab content. + * + * @param string $current_tab Current tab key. + * @return void + */ + private function render_printable_settings_tab( $current_tab ) { $season_id = $this->selected_season_id(); $seasons = $this->get_seasons(); $venues = $this->get_venues_for_season( $season_id ); @@ -253,9 +320,6 @@ if ( ! class_exists( 'Tony_Sportspress_Printable_Calendars' ) ) { $season_overrides = isset( $overrides[ $season_key ] ) && is_array( $overrides[ $season_key ] ) ? $overrides[ $season_key ] : array(); $season_primary_flags = isset( $primary_flags[ $season_key ] ) && is_array( $primary_flags[ $season_key ] ) ? $primary_flags[ $season_key ] : array(); - echo '
'; - echo '

' . esc_html__( 'Tony\'s Settings', 'tonys-sportspress-enhancements' ) . '

'; - $this->render_settings_tabs( $current_tab ); echo '
'; settings_fields( self::OPTION_GROUP ); @@ -325,15 +389,15 @@ if ( ! class_exists( 'Tony_Sportspress_Printable_Calendars' ) ) { $palette_count = count( $this->suggested_palette ); foreach ( $venues as $index => $venue ) { - $venue_id = (int) $venue['id']; - $venue_name = isset( $venue['name'] ) ? (string) $venue['name'] : ''; - $saved = isset( $season_overrides[ (string) $venue_id ] ) && is_string( $season_overrides[ (string) $venue_id ] ) ? $season_overrides[ (string) $venue_id ] : ''; - $suggested = $this->suggested_palette[ $index % max( 1, $palette_count ) ]; - $value = '' !== $saved ? $saved : $suggested; - $adjusted = $this->adjust_for_white_text( $value, self::MIN_WHITE_CONTRAST ); - $name = self::OPTION_KEY . '[venue_color_overrides][' . $season_key . '][' . $venue_id . ']'; + $venue_id = (int) $venue['id']; + $venue_name = isset( $venue['name'] ) ? (string) $venue['name'] : ''; + $saved = isset( $season_overrides[ (string) $venue_id ] ) && is_string( $season_overrides[ (string) $venue_id ] ) ? $season_overrides[ (string) $venue_id ] : ''; + $suggested = $this->suggested_palette[ $index % max( 1, $palette_count ) ]; + $value = '' !== $saved ? $saved : $suggested; + $adjusted = $this->adjust_for_white_text( $value, self::MIN_WHITE_CONTRAST ); + $name = self::OPTION_KEY . '[venue_color_overrides][' . $season_key . '][' . $venue_id . ']'; $primary_name = self::OPTION_KEY . '[venue_use_team_primary][' . $season_key . '][' . $venue_id . ']'; - $use_primary = isset( $season_primary_flags[ (string) $venue_id ] ) && '1' === $season_primary_flags[ (string) $venue_id ]; + $use_primary = isset( $season_primary_flags[ (string) $venue_id ] ) && '1' === $season_primary_flags[ (string) $venue_id ]; echo ''; echo '' . esc_html( $venue_name ) . ''; @@ -349,46 +413,203 @@ if ( ! class_exists( 'Tony_Sportspress_Printable_Calendars' ) ) { submit_button( __( 'Save Settings', 'tonys-sportspress-enhancements' ) ); echo '
'; - echo '
'; + + $this->render_printable_url_builder( $season_id ); } /** - * Render Tony's settings tabs. + * Render printable calendar URL builder. * - * @param string $current_tab Current tab key. + * @param int $season_id Current season context. + * @return void */ - private function render_settings_tabs( $current_tab ) { - $tabs = array( - self::TAB_PRINTABLE => __( 'Printable Calendars', 'tonys-sportspress-enhancements' ), - ); + private function render_printable_url_builder( $season_id ) { + $leagues = function_exists( 'tse_sp_schedule_exporter_get_leagues' ) ? tse_sp_schedule_exporter_get_leagues() : array(); + $teams = function_exists( 'tse_sp_schedule_exporter_get_teams' ) ? tse_sp_schedule_exporter_get_teams() : array(); + $paper = '11x17'; - echo '