diff --git a/includes/sp-event-csv.php b/includes/sp-event-csv.php new file mode 100644 index 0000000..079aaff --- /dev/null +++ b/includes/sp-event-csv.php @@ -0,0 +1,1592 @@ + 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. + * + * Empty field key means "accepted but ignored". + * + * @return array + */ +function tse_sp_event_csv_recognized_headers() { + return array( + 'Date' => 'date', + 'Time' => 'time', + 'Venue' => 'venue', + 'Home' => 'home', + 'Away' => 'away', + 'Week / Round' => 'week_round', + 'Division' => '', + 'Label' => '', + 'League' => 'league', + 'Season' => 'season', + 'Home Score' => 'home_score', + 'Away Score' => 'away_score', + 'Referee' => '', + 'Assistant Referees' => '', + 'Notes' => 'notes', + ); +} + +/** + * Capability used for importer access. + * + * @return string + */ +function tse_sp_event_csv_importer_capability() { + return current_user_can( 'manage_sportspress' ) ? 'manage_sportspress' : 'manage_options'; +} + +/** + * Get available SportsPress event formats. + * + * @return array + */ +function tse_sp_event_csv_event_formats() { + $formats = array(); + + if ( function_exists( 'SP' ) && is_object( SP() ) && isset( SP()->formats->event ) && is_array( SP()->formats->event ) ) { + $formats = SP()->formats->event; + } + + if ( empty( $formats ) ) { + $formats = array( + 'league' => __( 'Competitive', 'sportspress' ), + 'friendly' => __( 'Friendly', 'sportspress' ), + ); + } + + return $formats; +} + +/** + * Validate incoming event format against available SportsPress formats. + * + * @param string $event_format Proposed format key. + * @return string + */ +function tse_sp_event_csv_validate_event_format( $event_format ) { + $event_format = sanitize_key( (string) $event_format ); + $formats = tse_sp_event_csv_event_formats(); + + if ( isset( $formats[ $event_format ] ) ) { + return $event_format; + } + + return 'league'; +} + +/** + * Normalize an outcome token for case-insensitive matching. + * + * @param string $value Outcome value. + * @return string + */ +function tse_sp_event_csv_normalize_outcome_token( $value ) { + $value = wp_strip_all_tags( (string) $value ); + $value = html_entity_decode( $value, ENT_QUOTES, get_bloginfo( 'charset' ) ); + $value = strtolower( trim( preg_replace( '/\s+/', ' ', $value ) ) ); + return $value; +} + +/** + * Build and cache SportsPress outcome maps. + * + * @param bool $refresh Force cache rebuild. + * @return array + */ +function tse_sp_event_csv_outcome_catalog( $refresh = false ) { + static $catalog = null; + + if ( ! $refresh && null !== $catalog ) { + return $catalog; + } + + $catalog = array( + 'slugs' => array(), + 'titles' => array(), + 'compact_slugs' => array(), + ); + + $outcome_posts = get_posts( + array( + 'post_type' => 'sp_outcome', + 'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ), + 'posts_per_page' => -1, + 'no_found_rows' => true, + ) + ); + + foreach ( $outcome_posts as $outcome_post ) { + if ( empty( $outcome_post->post_name ) ) { + continue; + } + + $slug = (string) $outcome_post->post_name; + $title = (string) $outcome_post->post_title; + + $catalog['slugs'][ $slug ] = $slug; + $catalog['titles'][ tse_sp_event_csv_normalize_outcome_token( $title ) ] = $slug; + $catalog['compact_slugs'][ preg_replace( '/[^a-z0-9]/', '', strtolower( $slug ) ) ] = $slug; + } + + return $catalog; +} + +/** + * Resolve a user-entered outcome token to an outcome slug. + * + * Creates outcome posts when a custom value is supplied and no match exists. + * + * @param string $value Outcome input value. + * @return string + */ +function tse_sp_event_csv_resolve_outcome_slug( $value ) { + $value = sanitize_text_field( wp_strip_all_tags( (string) $value ) ); + $value = trim( $value ); + if ( '' === $value ) { + return ''; + } + + $catalog = tse_sp_event_csv_outcome_catalog(); + $raw_slug = sanitize_key( $value ); + $slug_guess = sanitize_title( $value ); + $title_key = tse_sp_event_csv_normalize_outcome_token( $value ); + $compact_key = preg_replace( '/[^a-z0-9]/', '', strtolower( $value ) ); + + if ( '' !== $raw_slug && isset( $catalog['slugs'][ $raw_slug ] ) ) { + return $catalog['slugs'][ $raw_slug ]; + } + if ( '' !== $slug_guess && isset( $catalog['slugs'][ $slug_guess ] ) ) { + return $catalog['slugs'][ $slug_guess ]; + } + if ( '' !== $title_key && isset( $catalog['titles'][ $title_key ] ) ) { + return $catalog['titles'][ $title_key ]; + } + if ( '' !== $compact_key && isset( $catalog['compact_slugs'][ $compact_key ] ) ) { + return $catalog['compact_slugs'][ $compact_key ]; + } + + $title = preg_match( '/\s/', $value ) ? $value : ucwords( str_replace( array( '-', '_' ), ' ', $value ) ); + $post_id = wp_insert_post( + array( + 'post_type' => 'sp_outcome', + 'post_status' => 'publish', + 'post_title' => wp_strip_all_tags( $title ), + ), + true + ); + + if ( is_wp_error( $post_id ) || ! $post_id ) { + return ''; + } + + update_post_meta( $post_id, '_sp_import', 1 ); + + $outcome = get_post( $post_id ); + if ( ! $outcome || empty( $outcome->post_name ) ) { + return ''; + } + + tse_sp_event_csv_outcome_catalog( true ); + + return (string) $outcome->post_name; +} + +/** + * Parse an outcome input value into an array of outcome slugs. + * + * @param string $value Outcome input value. + * @return array + */ +function tse_sp_event_csv_parse_outcome_value( $value ) { + $value = trim( (string) $value ); + if ( '' === $value ) { + return array(); + } + + $tokens = preg_split( '/[\|,]/', $value ); + $outcomes = array(); + + foreach ( $tokens as $token ) { + $slug = tse_sp_event_csv_resolve_outcome_slug( $token ); + if ( '' !== $slug ) { + $outcomes[ $slug ] = $slug; + } + } + + return array_values( $outcomes ); +} + +/** + * Build default home/away outcomes based on score. + * + * @param string $home_score Home score. + * @param string $away_score Away score. + * @return array + */ +function tse_sp_event_csv_default_outcomes( $home_score, $away_score ) { + if ( ! is_numeric( $home_score ) || ! is_numeric( $away_score ) ) { + return array( + 'home' => '', + 'away' => '', + ); + } + + $home_score = (float) $home_score; + $away_score = (float) $away_score; + + if ( $home_score > $away_score ) { + return array( + 'home' => 'win', + 'away' => 'loss', + ); + } + + if ( $away_score > $home_score ) { + return array( + 'home' => 'loss', + 'away' => 'win', + ); + } + + return array( + 'home' => 'tie', + 'away' => 'tie', + ); +} + +/** + * Get suggested outcome slugs for preview inputs. + * + * @return array + */ +function tse_sp_event_csv_outcome_suggestions() { + $suggestions = array( + 'technicalforfeitwin', + 'technicalforfeitloss', + 'win', + 'loss', + 'tie', + 'forfeitwin', + 'forfeitloss', + ); + + $catalog = tse_sp_event_csv_outcome_catalog(); + foreach ( array_keys( $catalog['slugs'] ) as $slug ) { + $suggestions[] = $slug; + } + + $suggestions = array_values( array_unique( array_filter( $suggestions ) ) ); + sort( $suggestions ); + + return $suggestions; +} + +/** + * Register importer in WordPress Tools > Import screen. + */ +function tse_sp_event_csv_register_importer() { + if ( ! is_admin() ) { + return; + } + + if ( ! function_exists( 'register_importer' ) ) { + require_once ABSPATH . 'wp-admin/includes/import.php'; + } + + if ( ! function_exists( 'register_importer' ) ) { + return; + } + + register_importer( + 'tse_sp_event_csv', + __( 'SportsPress Events CSV', 'tonys-sportspress-enhancements' ), + __( 'Import SportsPress events directly from CSV with preview and duplicate detection. Provided by Tony\'s SportsPress Enhancements.', 'tonys-sportspress-enhancements' ), + 'tse_sp_event_csv_importer_bootstrap' + ); +} +add_action( 'admin_init', 'tse_sp_event_csv_register_importer' ); + +/** + * Importer callback wrapper for WordPress importer screen. + */ +function tse_sp_event_csv_importer_bootstrap() { + if ( ! current_user_can( 'import' ) ) { + wp_die( esc_html__( 'You do not have permission to import.', 'tonys-sportspress-enhancements' ) ); + } + + tse_sp_event_csv_importer_page(); +} + +/** + * Get transient key for current user's preview payload. + * + * @return string + */ +function tse_sp_event_csv_preview_key() { + return 'tse_sp_event_csv_preview_' . get_current_user_id(); +} + +/** + * Normalize header labels (trim/BOM cleanup). + * + * @param string $header Header label. + * @return string + */ +function tse_sp_event_csv_normalize_header( $header ) { + $header = trim( (string) $header ); + return (string) preg_replace( '/^\xEF\xBB\xBF/', '', $header ); +} + +/** + * Build CSV column index map from incoming header. + * + * @param array $header Normalized CSV header row. + * @return array|WP_Error + */ +function tse_sp_event_csv_build_header_map( $header ) { + $recognized = tse_sp_event_csv_recognized_headers(); + $map = array(); + + foreach ( $header as $index => $column ) { + $field = isset( $recognized[ $column ] ) ? $recognized[ $column ] : null; + if ( null === $field || '' === $field ) { + continue; + } + + if ( ! isset( $map[ $field ] ) ) { + $map[ $field ] = (int) $index; + } + } + + $required = array( + 'date' => 'Date', + 'home' => 'Home', + 'away' => 'Away', + 'home_score' => 'Home Score', + 'away_score' => 'Away Score', + ); + $missing = array(); + foreach ( $required as $required_field => $required_label ) { + if ( ! isset( $map[ $required_field ] ) ) { + $missing[] = $required_label; + } + } + + if ( ! empty( $missing ) ) { + return new WP_Error( + 'invalid_header', + sprintf( + /* translators: %s: missing required CSV columns. */ + __( 'CSV header is missing required columns: %s', 'tonys-sportspress-enhancements' ), + implode( ', ', $missing ) + ) + ); + } + + return $map; +} + +/** + * Get a normalized CSV cell by logical field. + * + * @param array $values CSV row values. + * @param array $header_map Field->index map. + * @param string $field Logical field key. + * @return string + */ +function tse_sp_event_csv_row_value( $values, $header_map, $field ) { + if ( ! isset( $header_map[ $field ] ) ) { + return ''; + } + + $index = (int) $header_map[ $field ]; + if ( ! isset( $values[ $index ] ) ) { + return ''; + } + + return trim( (string) $values[ $index ] ); +} + +/** + * Normalize team lookup tokens. + * + * @param string $value Raw team text. + * @return string + */ +function tse_sp_event_csv_normalize_team_token( $value ) { + $value = wp_strip_all_tags( (string) $value ); + $value = trim( $value ); + if ( '' === $value ) { + return ''; + } + + $value = html_entity_decode( $value, ENT_QUOTES, get_bloginfo( 'charset' ) ); + $value = preg_replace( '/\s+/', ' ', $value ); + $value = strtolower( $value ); + + return trim( (string) $value ); +} + +/** + * Build a normalized lookup map for existing SportsPress teams. + * + * @param bool $refresh Force cache rebuild. + * @return array + */ +function tse_sp_event_csv_team_lookup_map( $refresh = false ) { + static $map = null; + + if ( ! $refresh && null !== $map ) { + return $map; + } + + $map = array(); + $team_ids = get_posts( + array( + 'post_type' => 'sp_team', + 'post_status' => array( 'publish', 'draft', 'pending', 'future', 'private' ), + 'posts_per_page' => -1, + 'fields' => 'ids', + 'no_found_rows' => true, + ) + ); + + foreach ( $team_ids as $team_id ) { + $tokens = array( + get_the_title( $team_id ), + get_post_meta( $team_id, 'sp_short_name', true ), + get_post_meta( $team_id, 'sp_abbreviation', true ), + ); + + foreach ( $tokens as $token ) { + $key = tse_sp_event_csv_normalize_team_token( $token ); + if ( '' === $key ) { + continue; + } + if ( ! isset( $map[ $key ] ) ) { + $map[ $key ] = (int) $team_id; + } + } + } + + return $map; +} + +/** + * Find team post ID by exact title. + * + * @param string $team_name Team name. + * @return int + */ +function tse_sp_event_csv_find_team_id( $team_name ) { + $key = tse_sp_event_csv_normalize_team_token( $team_name ); + if ( '' === $key ) { + return 0; + } + + $map = tse_sp_event_csv_team_lookup_map(); + if ( isset( $map[ $key ] ) ) { + return (int) $map[ $key ]; + } + + return 0; +} + +/** + * Find existing team or create a new one. + * + * @param string $team_name Team name. + * @return int|WP_Error + */ +function tse_sp_event_csv_find_or_create_team( $team_name ) { + $team_name = trim( (string) $team_name ); + if ( '' === $team_name ) { + return new WP_Error( 'team_name_missing', __( 'Team name is required.', 'tonys-sportspress-enhancements' ) ); + } + + $team_id = tse_sp_event_csv_find_team_id( $team_name ); + if ( $team_id > 0 ) { + return $team_id; + } + + $team_id = wp_insert_post( + array( + 'post_type' => 'sp_team', + 'post_status' => 'publish', + 'post_title' => wp_strip_all_tags( $team_name ), + ), + true + ); + + if ( is_wp_error( $team_id ) ) { + return $team_id; + } + + update_post_meta( $team_id, '_sp_import', 1 ); + + // Refresh static lookup cache for subsequent rows in the same request. + tse_sp_event_csv_team_lookup_map( true ); + + return (int) $team_id; +} + +/** + * Parse date/time into local site datetime string. + * + * @param string $date_raw Date text. + * @param string $time_raw Time text. + * @return string|WP_Error + */ +function tse_sp_event_csv_parse_datetime( $date_raw, $time_raw ) { + $date_raw = trim( (string) $date_raw ); + $time_raw = trim( (string) $time_raw ); + + if ( '' === $date_raw ) { + return new WP_Error( 'missing_date', __( 'Date is required.', 'tonys-sportspress-enhancements' ) ); + } + + $input = $date_raw . ' ' . ( '' !== $time_raw ? $time_raw : '00:00' ); + $dt = date_create_immutable( $input, wp_timezone() ); + if ( false === $dt ) { + return new WP_Error( 'invalid_datetime', __( 'Date/Time could not be parsed.', 'tonys-sportspress-enhancements' ) ); + } + + return $dt->format( 'Y-m-d H:i:s' ); +} + +/** + * Build a deterministic event key from post_date and two team IDs. + * + * @param string $post_date Event post_date. + * @param int $team_a Team ID A. + * @param int $team_b Team ID B. + * @return string + */ +function tse_sp_event_csv_build_event_key( $post_date, $team_a, $team_b ) { + $post_date = trim( (string) $post_date ); + $teams = array_values( array_filter( array_map( 'intval', array( $team_a, $team_b ) ) ) ); + + if ( '' === $post_date || count( $teams ) < 2 ) { + return ''; + } + + sort( $teams ); + return $post_date . '|' . $teams[0] . '|' . $teams[1]; +} + +/** + * Build a row signature for in-file duplicate checks. + * + * @param string $post_date Event post_date. + * @param string $home Home team text. + * @param string $away Away team text. + * @return string + */ +function tse_sp_event_csv_build_row_signature( $post_date, $home, $away ) { + $post_date = trim( (string) $post_date ); + if ( '' === $post_date ) { + return ''; + } + + $teams = array( + tse_sp_event_csv_normalize_team_token( $home ), + tse_sp_event_csv_normalize_team_token( $away ), + ); + $teams = array_values( array_filter( $teams ) ); + if ( count( $teams ) < 2 ) { + return ''; + } + + sort( $teams ); + return $post_date . '|name:' . $teams[0] . '|name:' . $teams[1]; +} + +/** + * Build existing SportsPress event key map for duplicate detection. + * + * @return array + */ +function tse_sp_event_csv_existing_event_keys() { + static $keys = null; + + if ( null !== $keys ) { + return $keys; + } + + $keys = array(); + $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, + ) + ); + + foreach ( $event_ids as $event_id ) { + $post_date = (string) get_post_field( 'post_date', $event_id ); + $teams = array_values( array_filter( array_map( 'intval', (array) get_post_meta( $event_id, 'sp_team', false ) ) ) ); + if ( count( $teams ) < 2 ) { + continue; + } + + $key = tse_sp_event_csv_build_event_key( $post_date, $teams[0], $teams[1] ); + if ( '' !== $key ) { + $keys[ $key ] = (int) $event_id; + } + } + + return $keys; +} + +/** + * Get the primary result key from SportsPress result variables. + * + * @return string + */ +function tse_sp_event_csv_primary_result_key() { + if ( function_exists( 'sp_get_var_labels' ) ) { + $labels = sp_get_var_labels( 'sp_result' ); + if ( is_array( $labels ) && ! empty( $labels ) ) { + return (string) array_key_first( $labels ); + } + } + + return 'result'; +} + +/** + * Parse uploaded CSV into normalized preview payload. + * + * @param string $file_path Temp uploaded file path. + * @return array|WP_Error + */ +function tse_sp_event_csv_parse_file( $file_path ) { + $handle = fopen( $file_path, 'r' ); + if ( false === $handle ) { + return new WP_Error( 'file_open_failed', __( 'Unable to read uploaded CSV file.', 'tonys-sportspress-enhancements' ) ); + } + + $header = fgetcsv( $handle, 0, ',', '"', '\\' ); + if ( false === $header ) { + fclose( $handle ); + return new WP_Error( 'missing_header', __( 'CSV appears empty.', 'tonys-sportspress-enhancements' ) ); + } + + $header = array_map( 'tse_sp_event_csv_normalize_header', $header ); + $header_map = tse_sp_event_csv_build_header_map( $header ); + if ( is_wp_error( $header_map ) ) { + fclose( $handle ); + return $header_map; + } + + $rows = array(); + $total_rows = 0; + $rows_with_errors = 0; + $rows_with_new_teams = 0; + $rows_with_duplicates = 0; + $new_teams = array(); + $line_number = 1; + $seen_row_signatures = array(); + $existing_event_keys = tse_sp_event_csv_existing_event_keys(); + + while ( ( $values = fgetcsv( $handle, 0, ',', '"', '\\' ) ) !== false ) { + ++$line_number; + + if ( ! is_array( $values ) ) { + continue; + } + + if ( 1 === count( $values ) && '' === trim( (string) $values[0] ) ) { + continue; + } + + $normalized = array( + 'line' => $line_number, + 'date' => tse_sp_event_csv_row_value( $values, $header_map, 'date' ), + 'time' => tse_sp_event_csv_row_value( $values, $header_map, 'time' ), + 'venue' => tse_sp_event_csv_row_value( $values, $header_map, 'venue' ), + 'home' => tse_sp_event_csv_row_value( $values, $header_map, 'home' ), + 'away' => tse_sp_event_csv_row_value( $values, $header_map, 'away' ), + 'week_round' => tse_sp_event_csv_row_value( $values, $header_map, 'week_round' ), + 'league' => tse_sp_event_csv_row_value( $values, $header_map, 'league' ), + 'season' => tse_sp_event_csv_row_value( $values, $header_map, 'season' ), + 'home_score' => tse_sp_event_csv_row_value( $values, $header_map, 'home_score' ), + 'away_score' => tse_sp_event_csv_row_value( $values, $header_map, 'away_score' ), + 'notes' => tse_sp_event_csv_row_value( $values, $header_map, 'notes' ), + 'errors' => array(), + 'duplicate_existing' => false, + 'duplicate_file' => false, + 'home_team_id'=> 0, + 'away_team_id'=> 0, + ); + + if ( '' === $normalized['home'] ) { + $normalized['errors'][] = __( 'Home team is required.', 'tonys-sportspress-enhancements' ); + } + if ( '' === $normalized['away'] ) { + $normalized['errors'][] = __( 'Away team is required.', 'tonys-sportspress-enhancements' ); + } + + $parsed_datetime = tse_sp_event_csv_parse_datetime( $normalized['date'], $normalized['time'] ); + if ( is_wp_error( $parsed_datetime ) ) { + $normalized['errors'][] = $parsed_datetime->get_error_message(); + } else { + $normalized['post_date'] = $parsed_datetime; + } + + if ( '' !== $normalized['home'] ) { + $normalized['home_team_id'] = tse_sp_event_csv_find_team_id( $normalized['home'] ); + } + if ( '' !== $normalized['away'] ) { + $normalized['away_team_id'] = tse_sp_event_csv_find_team_id( $normalized['away'] ); + } + + $row_signature = tse_sp_event_csv_build_row_signature( + isset( $normalized['post_date'] ) ? $normalized['post_date'] : '', + $normalized['home'], + $normalized['away'] + ); + if ( '' !== $row_signature ) { + if ( isset( $seen_row_signatures[ $row_signature ] ) ) { + $normalized['duplicate_file'] = true; + } else { + $seen_row_signatures[ $row_signature ] = $line_number; + } + } + + $event_key = tse_sp_event_csv_build_event_key( + isset( $normalized['post_date'] ) ? $normalized['post_date'] : '', + $normalized['home_team_id'], + $normalized['away_team_id'] + ); + if ( '' !== $event_key && isset( $existing_event_keys[ $event_key ] ) ) { + $normalized['duplicate_existing'] = true; + } + + if ( $normalized['home_team_id'] <= 0 || $normalized['away_team_id'] <= 0 ) { + ++$rows_with_new_teams; + } + if ( $normalized['home_team_id'] <= 0 && '' !== $normalized['home'] ) { + $new_teams[ tse_sp_event_csv_normalize_team_token( $normalized['home'] ) ] = $normalized['home']; + } + if ( $normalized['away_team_id'] <= 0 && '' !== $normalized['away'] ) { + $new_teams[ tse_sp_event_csv_normalize_team_token( $normalized['away'] ) ] = $normalized['away']; + } + + if ( ! empty( $normalized['errors'] ) ) { + ++$rows_with_errors; + } + if ( $normalized['duplicate_existing'] || $normalized['duplicate_file'] ) { + ++$rows_with_duplicates; + } + + $rows[] = $normalized; + ++$total_rows; + } + + fclose( $handle ); + + return array( + 'created_at' => time(), + 'rows' => $rows, + 'total_rows' => $total_rows, + 'rows_with_errors' => $rows_with_errors, + 'rows_with_new_teams' => $rows_with_new_teams, + 'rows_with_duplicates' => $rows_with_duplicates, + 'unique_new_teams' => array_values( array_filter( $new_teams ) ), + 'result_key' => tse_sp_event_csv_primary_result_key(), + ); +} + +/** + * Import parsed preview payload into SportsPress events. + * + * @param array $preview Preview payload. + * @param array $options Import options. + * @return array + */ +function tse_sp_event_csv_run_import( $preview, $options = array() ) { + $results = array( + 'imported' => 0, + 'updated' => 0, + 'skipped' => 0, + 'excluded' => 0, + 'errors' => array(), + ); + + $result_key = isset( $preview['result_key'] ) ? (string) $preview['result_key'] : tse_sp_event_csv_primary_result_key(); + $delimiter = get_option( 'sportspress_event_teams_delimiter', 'vs' ); + $rows = ( isset( $preview['rows'] ) && is_array( $preview['rows'] ) ) ? $preview['rows'] : array(); + + $selection_mode = ! empty( $options['selection_mode'] ); + $include_lookup = ( isset( $options['include_lookup'] ) && is_array( $options['include_lookup'] ) ) ? $options['include_lookup'] : array(); + $duplicate_actions = ( isset( $options['duplicate_actions'] ) && is_array( $options['duplicate_actions'] ) ) ? $options['duplicate_actions'] : array(); + $home_outcome_inputs = ( isset( $options['home_outcomes'] ) && is_array( $options['home_outcomes'] ) ) ? $options['home_outcomes'] : array(); + $away_outcome_inputs = ( isset( $options['away_outcomes'] ) && is_array( $options['away_outcomes'] ) ) ? $options['away_outcomes'] : array(); + $event_format = isset( $options['event_format'] ) ? tse_sp_event_csv_validate_event_format( $options['event_format'] ) : 'league'; + $existing_event_keys = tse_sp_event_csv_existing_event_keys(); + + foreach ( $rows as $row ) { + $line = isset( $row['line'] ) ? (int) $row['line'] : 0; + + if ( $selection_mode && ! isset( $include_lookup[ $line ] ) ) { + ++$results['excluded']; + continue; + } + + if ( ! empty( $row['errors'] ) ) { + ++$results['skipped']; + $results['errors'][] = sprintf( + /* translators: %d: line number. */ + __( 'Line %d skipped (invalid preview row).', 'tonys-sportspress-enhancements' ), + $line + ); + continue; + } + + $post_date = isset( $row['post_date'] ) ? $row['post_date'] : ''; + $home_id = tse_sp_event_csv_find_or_create_team( isset( $row['home'] ) ? $row['home'] : '' ); + $away_id = tse_sp_event_csv_find_or_create_team( isset( $row['away'] ) ? $row['away'] : '' ); + if ( is_wp_error( $home_id ) || is_wp_error( $away_id ) ) { + ++$results['skipped']; + $results['errors'][] = sprintf( + /* translators: 1: line number, 2: error details. */ + __( 'Line %1$d skipped: %2$s', 'tonys-sportspress-enhancements' ), + $line, + is_wp_error( $home_id ) ? $home_id->get_error_message() : $away_id->get_error_message() + ); + continue; + } + + if ( '' === $post_date ) { + ++$results['skipped']; + $results['errors'][] = sprintf( + /* translators: %d: line number. */ + __( 'Line %d skipped: missing parsed date.', 'tonys-sportspress-enhancements' ), + $line + ); + continue; + } + + $event_key = tse_sp_event_csv_build_event_key( $post_date, (int) $home_id, (int) $away_id ); + $duplicate_action = isset( $duplicate_actions[ $line ] ) ? $duplicate_actions[ $line ] : 'ignore'; + if ( 'update' !== $duplicate_action ) { + $duplicate_action = 'ignore'; + } + + $is_update = false; + if ( '' !== $event_key && isset( $existing_event_keys[ $event_key ] ) ) { + if ( 'update' !== $duplicate_action ) { + ++$results['skipped']; + $results['errors'][] = sprintf( + /* translators: %d: line number. */ + __( 'Line %d skipped: duplicate event already exists for same date/time and teams.', 'tonys-sportspress-enhancements' ), + $line + ); + continue; + } + + $event_id = wp_update_post( + array( + 'ID' => (int) $existing_event_keys[ $event_key ], + 'post_date' => $post_date, + 'post_title' => trim( $row['home'] . ' ' . $delimiter . ' ' . $row['away'] ), + 'post_content' => isset( $row['notes'] ) ? wp_kses_post( $row['notes'] ) : '', + ), + true + ); + $is_update = true; + } else { + $event_id = wp_insert_post( + array( + 'post_type' => 'sp_event', + 'post_status' => 'publish', + 'post_date' => $post_date, + 'post_title' => trim( $row['home'] . ' ' . $delimiter . ' ' . $row['away'] ), + 'post_content' => isset( $row['notes'] ) ? wp_kses_post( $row['notes'] ) : '', + ), + true + ); + } + + if ( is_wp_error( $event_id ) || ! $event_id ) { + ++$results['skipped']; + $results['errors'][] = sprintf( + /* translators: 1: line number, 2: error details. */ + __( 'Line %1$d failed: %2$s', 'tonys-sportspress-enhancements' ), + $line, + is_wp_error( $event_id ) ? $event_id->get_error_message() : __( 'Unknown save error', 'tonys-sportspress-enhancements' ) + ); + continue; + } + + update_post_meta( $event_id, '_sp_import', 1 ); + update_post_meta( $event_id, 'sp_format', $event_format ); + + delete_post_meta( $event_id, 'sp_team' ); + add_post_meta( $event_id, 'sp_team', (int) $home_id ); + add_post_meta( $event_id, 'sp_team', (int) $away_id ); + if ( ! $is_update ) { + add_post_meta( $event_id, 'sp_player', 0 ); + add_post_meta( $event_id, 'sp_player', 0 ); + } + + if ( ! empty( $row['venue'] ) ) { + wp_set_object_terms( $event_id, sanitize_text_field( $row['venue'] ), 'sp_venue', false ); + } + if ( ! empty( $row['league'] ) ) { + $league_term = sanitize_text_field( $row['league'] ); + wp_set_object_terms( $event_id, $league_term, 'sp_league', false ); + + // Ensure teams are linked to every league they appear in. + wp_set_object_terms( (int) $home_id, $league_term, 'sp_league', true ); + wp_set_object_terms( (int) $away_id, $league_term, 'sp_league', true ); + } + if ( ! empty( $row['season'] ) ) { + $season_term = sanitize_text_field( $row['season'] ); + wp_set_object_terms( $event_id, $season_term, 'sp_season', false ); + + // Ensure teams are linked to every season they appear in. + wp_set_object_terms( (int) $home_id, $season_term, 'sp_season', true ); + wp_set_object_terms( (int) $away_id, $season_term, 'sp_season', true ); + } + + $home_score = isset( $row['home_score'] ) ? trim( (string) $row['home_score'] ) : ''; + $away_score = isset( $row['away_score'] ) ? trim( (string) $row['away_score'] ) : ''; + $default_outcomes = tse_sp_event_csv_default_outcomes( $home_score, $away_score ); + + $home_outcome_input = isset( $home_outcome_inputs[ $line ] ) ? (string) $home_outcome_inputs[ $line ] : ''; + $away_outcome_input = isset( $away_outcome_inputs[ $line ] ) ? (string) $away_outcome_inputs[ $line ] : ''; + + if ( '' === trim( $home_outcome_input ) ) { + $home_outcome_input = $default_outcomes['home']; + } + if ( '' === trim( $away_outcome_input ) ) { + $away_outcome_input = $default_outcomes['away']; + } + + $home_outcomes = tse_sp_event_csv_parse_outcome_value( $home_outcome_input ); + $away_outcomes = tse_sp_event_csv_parse_outcome_value( $away_outcome_input ); + + if ( '' !== $home_score || '' !== $away_score || ! empty( $home_outcomes ) || ! empty( $away_outcomes ) ) { + update_post_meta( + $event_id, + 'sp_results', + array( + (int) $home_id => array( + $result_key => $home_score, + 'outcome' => $home_outcomes, + ), + (int) $away_id => array( + $result_key => $away_score, + 'outcome' => $away_outcomes, + ), + ) + ); + } + + if ( $is_update ) { + ++$results['updated']; + } else { + ++$results['imported']; + } + + if ( '' !== $event_key ) { + $existing_event_keys[ $event_key ] = (int) $event_id; + } + } + + return $results; +} + +/** + * Render importer admin page and process actions. + */ +function tse_sp_event_csv_importer_page() { + if ( ! current_user_can( tse_sp_event_csv_importer_capability() ) ) { + wp_die( esc_html__( 'You do not have permission to access this importer.', 'tonys-sportspress-enhancements' ) ); + } + + $messages = array(); + $error_lines = array(); + $key = tse_sp_event_csv_preview_key(); + + $request_method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : ''; + if ( 'POST' === $request_method ) { + $action = isset( $_POST['tse_action'] ) ? sanitize_key( wp_unslash( $_POST['tse_action'] ) ) : ''; + + if ( 'preview' === $action ) { + check_admin_referer( 'tse_sp_event_csv_preview' ); + + if ( empty( $_FILES['tse_csv_file']['tmp_name'] ) || ! is_uploaded_file( $_FILES['tse_csv_file']['tmp_name'] ) ) { + $messages[] = array( + 'type' => 'error', + 'text' => __( 'Please choose a CSV file to preview.', 'tonys-sportspress-enhancements' ), + ); + } else { + $preview = tse_sp_event_csv_parse_file( $_FILES['tse_csv_file']['tmp_name'] ); + if ( is_wp_error( $preview ) ) { + $messages[] = array( + 'type' => 'error', + 'text' => $preview->get_error_message(), + ); + } else { + set_transient( $key, $preview, 6 * HOUR_IN_SECONDS ); + $messages[] = array( + 'type' => 'success', + 'text' => __( 'Preview ready. Review rows below, then import or abort.', 'tonys-sportspress-enhancements' ), + ); + } + } + } elseif ( 'abort' === $action ) { + check_admin_referer( 'tse_sp_event_csv_abort' ); + delete_transient( $key ); + $messages[] = array( + 'type' => 'success', + 'text' => __( 'Preview aborted. Nothing was imported.', 'tonys-sportspress-enhancements' ), + ); + } elseif ( 'import' === $action ) { + check_admin_referer( 'tse_sp_event_csv_import' ); + $preview = get_transient( $key ); + if ( ! is_array( $preview ) || empty( $preview['rows'] ) ) { + $messages[] = array( + 'type' => 'error', + 'text' => __( 'No preview data found. Upload the CSV again.', 'tonys-sportspress-enhancements' ), + ); + } else { + $selection_mode = isset( $_POST['tse_has_row_selection'] ); + $include_lookup = array(); + if ( $selection_mode && isset( $_POST['include_rows'] ) && is_array( $_POST['include_rows'] ) ) { + foreach ( $_POST['include_rows'] as $line => $include_flag ) { + $line = absint( $line ); + if ( $line > 0 && '1' === (string) $include_flag ) { + $include_lookup[ $line ] = true; + } + } + } + + $duplicate_actions = array(); + if ( isset( $_POST['duplicate_actions'] ) && is_array( $_POST['duplicate_actions'] ) ) { + foreach ( $_POST['duplicate_actions'] as $line => $action_choice ) { + $line = absint( $line ); + if ( $line < 1 ) { + continue; + } + + $action_choice = sanitize_key( wp_unslash( $action_choice ) ); + $duplicate_actions[ $line ] = in_array( $action_choice, array( 'ignore', 'update' ), true ) ? $action_choice : 'ignore'; + } + } + + $event_format = isset( $_POST['event_format'] ) ? tse_sp_event_csv_validate_event_format( wp_unslash( $_POST['event_format'] ) ) : 'league'; + + $results = tse_sp_event_csv_run_import( + $preview, + array( + 'selection_mode' => $selection_mode, + 'include_lookup' => $include_lookup, + 'duplicate_actions' => $duplicate_actions, + 'event_format' => $event_format, + ) + ); + delete_transient( $key ); + + $messages[] = array( + 'type' => 'success', + 'text' => sprintf( + /* translators: 1: imported count, 2: updated count, 3: skipped count, 4: excluded count. */ + __( 'Import complete. Imported: %1$d. Updated: %2$d. Skipped: %3$d. Excluded: %4$d.', 'tonys-sportspress-enhancements' ), + (int) $results['imported'], + (int) $results['updated'], + (int) $results['skipped'], + (int) $results['excluded'] + ), + ); + $error_lines = isset( $results['errors'] ) && is_array( $results['errors'] ) ? $results['errors'] : array(); + } + } + } + + $preview_data = get_transient( $key ); + ?> +
+

+

+ + +
+

+
+ + + +
+

+ +
+ + +
+ + + + +
+ + +
+

+

+ +

+ +

+ +

+ + +
+ + + + +

+
+ + +

+

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ + / + +
+ + + /> + + + + () + + + + + () + + + + + + + + + — + +
+ +
+ +
+
+ +
+ + + +
+ + + +
+