From 0fb47a437208e474909727da3f35aef87748ec64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jacek=20Fefli=C5=84ski?= Date: Thu, 29 Jan 2026 21:24:35 +0100 Subject: [PATCH] Import activites, shortcodes in two columns --- moje-statystyki.php | 416 +++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 391 insertions(+), 25 deletions(-) diff --git a/moje-statystyki.php b/moje-statystyki.php index 7c19712..9874162 100644 --- a/moje-statystyki.php +++ b/moje-statystyki.php @@ -220,6 +220,15 @@ function mystat_add_admin_menu() { 'mystat-infographic', // Slug podmenu 'mystat_infographic_page' // Funkcja renderująca ); + + add_submenu_page( + 'moje-statystyki', // Slug rodzica + 'Import CSV', // Tytuł strony + 'Import CSV', // Tytuł w podmenu + 'manage_options', // Wymagane uprawnienia + 'mystat-import-csv', // Slug podmenu + 'mystat_import_csv_page' // Funkcja renderująca + ); } function mystat_dashboard_page() { @@ -438,7 +447,21 @@ function mystat_yearly_summary_page() { $total_year_calories = 0; $total_year_seconds = 0; - for ( $i = 1; $i <= 12; $i++ ) { + // Określ, ile miesięcy pokazać, aby uniknąć zer dla przyszłych miesięcy + $this_year = (int) current_time('Y'); + $this_month = (int) current_time('n'); + $loop_until_month = 12; // Domyślnie dla lat ubiegłych + + if ( $current_year == $this_year ) { + // Dla bieżącego roku, pokaż miesiące do bieżącego miesiąca + $loop_until_month = $this_month; + } elseif ( $current_year > $this_year ) { + // Dla przyszłych lat, pokaż miesiące tylko do ostatniego, w którym są dane + $last_month_with_data = $wpdb->get_var( $wpdb->prepare( "SELECT MAX(MONTH(date)) FROM $table_activities WHERE YEAR(date) = %d", $current_year ) ); + $loop_until_month = $last_month_with_data ? (int) $last_month_with_data : 0; + } + + for ( $i = 1; $i <= $loop_until_month; $i++ ) { $month_name = date_i18n( 'F', mktime( 0, 0, 0, $i, 10 ) ); // Nazwa miesiąca $data = isset( $monthly_summary[$i] ) ? $monthly_summary[$i] : null; @@ -746,6 +769,294 @@ function mystat_infographic_page() {

Importuj aktywności z pliku CSV

'; + + // Handle the form submission + if ( 'POST' === $_SERVER['REQUEST_METHOD'] && isset( $_POST['mystat_csv_import_nonce_field'] ) ) { + mystat_handle_csv_import(); + } + + // Display the form + ?> +
+

Instrukcje i formularz importu

+
+

Aby zaimportować dane, możesz wgrać plik CSV LUB wkleić dane bezpośrednio w pole tekstowe poniżej. Ta druga opcja jest zalecana, jeśli napotykasz błędy z plikiem.

+

Wymagane kolumny: Data, Tytuł, Dystans oraz Typ aktywności (lub Kategoria).

+

Opcjonalne kolumny: Czas (format HH:MM:SS), Kalorie, Średnie tętno, Maksymalne tętno, Średnia prędkość, Maksymalna prędkość, Średni rytm pedałowania, Maksymalny rytm pedałowania, Całkowity wznios, Całkowity spadek, Minimalna wysokość, Maksymalna wysokość, Sprzęt, Typ wydarzenia.

+

Ważne: +

    +
  • Data musi być w formacie YYYY-MM-DD.
  • +
  • Dystans i prędkość: użyj kropki jako separatora dziesiętnego (np. 10.5).
  • +
  • Nazwy w kolumnach Typ aktywności, Sprzęt, Typ wydarzenia muszą dokładnie odpowiadać nazwom zdefiniowanym w ustawieniach wtyczki. Jeśli nazwa nie zostanie znaleziona, wiersz zostanie pominięty.
  • +
+

+
+
+ + + + + + + + + + +
+ +
+
+
+ '; +} + +function mystat_handle_csv_import() { + global $wpdb; + + if ( ! isset( $_POST['mystat_csv_import_nonce_field'] ) || ! wp_verify_nonce( $_POST['mystat_csv_import_nonce_field'], 'mystat_csv_import_nonce' ) ) { + echo '

Błąd weryfikacji bezpieczeństwa.

'; + return; + } + + if ( ! current_user_can( 'manage_options' ) ) { + echo '

Nie masz wystarczających uprawnień.

'; + return; + } + + // Unify input source: prefer textarea, fall back to file upload. + $csv_content = ''; + if ( ! empty( $_POST['mystat_csv_data'] ) ) { + $csv_content = stripslashes( $_POST['mystat_csv_data'] ); + } elseif ( ! empty( $_FILES['mystat_csv_file']['tmp_name'] ) && UPLOAD_ERR_OK === $_FILES['mystat_csv_file']['error'] ) { + $csv_content = file_get_contents( $_FILES['mystat_csv_file']['tmp_name'] ); + } + + if ( empty( trim( $csv_content ) ) ) { + echo '

Nie podano danych do importu. Wgraj plik lub wklej dane w pole tekstowe.

'; + return; + } + + // Mapowanie polskich i angielskich nazw kolumn na wewnętrzne klucze + $column_map = [ + // Polish => English + 'typ aktywności' => 'category_name', + 'data' => 'date', + 'tytuł' => 'title', + 'dystans' => 'distance', + 'kalorie' => 'calories', + 'czas' => 'duration', + 'średnie tętno' => 'avg_heart_rate', + 'maksymalne tętno' => 'max_heart_rate', + 'średnia prędkość' => 'avg_speed', + 'maksymalna prędkość' => 'max_speed', + 'średni rytm pedałowania' => 'avg_cadence', + 'maksymalny rytm pedałowania' => 'max_cadence', + 'całkowity wznios' => 'total_elevation_gain', + 'całkowity spadek' => 'total_elevation_loss', + 'minimalna wysokość' => 'min_altitude', + 'maksymalna wysokość' => 'max_altitude', + 'sprzęt' => 'equipment_name', + 'typ wydarzenia' => 'event_type_name', + 'komentarz' => 'comment', + 'link do strava' => 'strava_url', + // English keys for compatibility + 'category_name' => 'category_name', 'date' => 'date', 'title' => 'title', 'distance' => 'distance', + 'calories' => 'calories', 'duration' => 'duration', 'avg_heart_rate' => 'avg_heart_rate', + 'max_heart_rate' => 'max_heart_rate', 'avg_speed' => 'avg_speed', 'max_speed' => 'max_speed', + 'avg_cadence' => 'avg_cadence', 'max_cadence' => 'max_cadence', 'total_elevation_gain' => 'total_elevation_gain', + 'total_elevation_loss' => 'total_elevation_loss', 'min_altitude' => 'min_altitude', 'max_altitude' => 'max_altitude', + 'equipment_name' => 'equipment_name', 'event_type_name' => 'event_type_name', 'comment' => 'comment', 'strava_url' => 'strava_url', + ]; + + // --- START: Robust, case-insensitive lookup --- + $table_categories = $wpdb->prefix . 'mystat_categories'; + $table_event_types = $wpdb->prefix . 'mystat_event_types'; + $table_equipment = $wpdb->prefix . 'mystat_equipment'; + + $create_lookup = function( $table_name ) use ( $wpdb ) { + $items = $wpdb->get_results( "SELECT id, name FROM {$table_name}" ); + $lookup = []; + if ( is_array( $items ) ) { + foreach ( $items as $item ) { + // Use a robust trim to handle various whitespace characters and make it case-insensitive + $clean_name = preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $item->name ); + $lookup[ mb_strtolower( $clean_name, 'UTF-8' ) ] = $item->id; + } + } + return $lookup; + }; + + $categories_lookup = $create_lookup( $table_categories ); + $event_types_lookup = $create_lookup( $table_event_types ); + $equipment_lookup = $create_lookup( $table_equipment ); + // --- END: Robust, case-insensitive lookup --- + + // Process the CSV file + $table_activities = $wpdb->prefix . 'mystat_activities'; + $imported_count = 0; + $skipped_details = []; + $row_number = 1; // Header is row 1 + + // Normalize line endings and split into lines + $lines = str_replace( ["\r\n", "\r"], "\n", $csv_content ); + $lines = explode( "\n", $lines ); + + if ( empty($lines) || empty(trim($lines[0])) ) { + echo '

Podane dane CSV są puste.

'; + return; + } + + // --- Delimiter and BOM detection --- + $first_line = $lines[0]; + $delimiter = (substr_count($first_line, ';') > substr_count($first_line, ',')) ? ';' : ','; + + // --- BOM removal from first header element --- + $bom = "\xEF\xBB\xBF"; + if (substr($first_line, 0, 3) === $bom) { + $lines[0] = substr($first_line, 3); + } + + $header_raw = str_getcsv( array_shift( $lines ), $delimiter ); + $header_raw = array_map('trim', $header_raw); + + // Translate header from Polish/English to internal keys + $header = []; + foreach ($header_raw as $col) { + $header[] = $column_map[strtolower($col)] ?? 'ignored_' . uniqid(); + } + + $required_internal_keys = ['date', 'title', 'category_name', 'distance']; + $missing_keys = array_diff($required_internal_keys, $header); + if (!empty($missing_keys)) { + $key_to_polish_map = [ + 'date' => 'Data', 'title' => 'Tytuł', + 'category_name' => 'Kategoria / Typ aktywności', 'distance' => 'Dystans', + ]; + $missing_polish_names = array_map(fn($key) => $key_to_polish_map[$key] ?? $key, $missing_keys); + echo '

Brak wymaganych kolumn w danych CSV: ' . esc_html(implode(', ', $missing_polish_names)) . '

'; + return; + } + + $parse_and_round_int = fn($val) => round(floatval(str_replace(',', '.', $val))); + + $null_if_empty = fn($value) => $value !== '' ? $value : null; + + foreach ( $lines as $line ) { // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition.Found + $row_number++; + if ( empty( trim( $line ) ) ) { + continue; // Skip empty lines + } + $data = str_getcsv( $line, $delimiter ); + + if (count($data) !== count($header)) { + $skipped_details[] = [ + 'row' => $row_number, + 'reason' => 'Nieprawidłowa liczba kolumn (oczekiwano ' . count($header) . ', otrzymano ' . count($data) . ').', + 'data' => $line, + ]; + continue; + } + $row_data = array_combine( $header, $data ); + + // Detailed validation + $validation_errors = []; + if ( empty( $row_data['date'] ) ) { + $validation_errors[] = 'brak daty'; + } + if ( empty( $row_data['title'] ) ) { + $validation_errors[] = 'brak tytułu'; + } + if ( ! isset( $row_data['distance'] ) || $row_data['distance'] === '' ) { + $validation_errors[] = 'brak dystansu'; + } + + $category_name = $row_data['category_name'] ?? ''; + // Use a robust trim to handle various whitespace characters and make it case-insensitive + $clean_category_name = preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $category_name ); + $category_id = $categories_lookup[ mb_strtolower( $clean_category_name, 'UTF-8' ) ] ?? null; + + if ( empty( $clean_category_name ) ) { + $validation_errors[] = 'brak nazwy kategorii'; + } elseif ( is_null( $category_id ) ) { + $available_categories_from_db = $wpdb->get_col( "SELECT name FROM $table_categories ORDER BY name" ); + $validation_errors[] = 'nieznana kategoria: "' . esc_html( $category_name ) . '". Sprawdź, czy nazwa jest poprawna. Dostępne w bazie: "' . esc_html( implode( '", "', $available_categories_from_db ) ) . '".'; + } + + if ( ! empty( $validation_errors ) ) { + $skipped_details[] = [ + 'row' => $row_number, + 'reason' => ucfirst( implode( ', ', $validation_errors ) ) . '.', + 'data' => $line, + ]; + continue; + } + + // Get IDs for optional fields using the same robust method + $get_id = function( $name, $lookup_table ) { + $clean_name = preg_replace( '/^[\pZ\pC]+|[\pZ\pC]+$/u', '', $name ); + return $lookup_table[ mb_strtolower( $clean_name, 'UTF-8' ) ] ?? null; + }; + + $equipment_id = $get_id( $row_data['equipment_name'] ?? '', $equipment_lookup ); + $event_type_id = $get_id( $row_data['event_type_name'] ?? '', $event_types_lookup ); + + $insert_data = [ + 'date' => sanitize_text_field( $row_data['date'] ), + 'title' => sanitize_text_field( $row_data['title'] ), + 'category_id' => $category_id, + 'distance' => floatval( str_replace( ',', '.', $row_data['distance'] ) ), + 'duration' => isset( $row_data['duration'] ) ? sanitize_text_field( $row_data['duration'] ) : '00:00:00', + 'calories' => isset( $row_data['calories'] ) ? intval( str_replace( ',', '.', $row_data['calories'] ) ) : 0, + 'comment' => isset( $row_data['comment'] ) ? sanitize_textarea_field( $row_data['comment'] ) : null, + 'strava_url' => isset( $row_data['strava_url'] ) ? $null_if_empty( esc_url_raw( $row_data['strava_url'] ) ) : null, + 'avg_heart_rate' => isset( $row_data['avg_heart_rate'] ) ? $null_if_empty( $parse_and_round_int( $row_data['avg_heart_rate'] ) ) : null, + 'max_heart_rate' => isset( $row_data['max_heart_rate'] ) ? $null_if_empty( $parse_and_round_int( $row_data['max_heart_rate'] ) ) : null, + 'avg_speed' => isset( $row_data['avg_speed'] ) ? $null_if_empty( floatval( str_replace( ',', '.', $row_data['avg_speed'] ) ) ) : null, + 'max_speed' => isset( $row_data['max_speed'] ) ? $null_if_empty( floatval( str_replace( ',', '.', $row_data['max_speed'] ) ) ) : null, + 'avg_cadence' => isset( $row_data['avg_cadence'] ) ? $null_if_empty( $parse_and_round_int( $row_data['avg_cadence'] ) ) : null, + 'max_cadence' => isset( $row_data['max_cadence'] ) ? $null_if_empty( $parse_and_round_int( $row_data['max_cadence'] ) ) : null, + 'total_elevation_gain' => isset( $row_data['total_elevation_gain'] ) ? $null_if_empty( $parse_and_round_int( $row_data['total_elevation_gain'] ) ) : null, + 'total_elevation_loss' => isset( $row_data['total_elevation_loss'] ) ? $null_if_empty( $parse_and_round_int( $row_data['total_elevation_loss'] ) ) : null, + 'min_altitude' => isset( $row_data['min_altitude'] ) ? $null_if_empty( $parse_and_round_int( $row_data['min_altitude'] ) ) : null, + 'max_altitude' => isset( $row_data['max_altitude'] ) ? $null_if_empty( $parse_and_round_int( $row_data['max_altitude'] ) ) : null, + 'equipment_id' => $equipment_id, + 'event_type_id' => $event_type_id, + ]; + + if ( $wpdb->insert( $table_activities, $insert_data ) ) { + $imported_count++; + } else { + $skipped_details[] = [ + 'row' => $row_number, + 'reason' => 'Błąd zapisu do bazy danych. (' . esc_html( $wpdb->last_error ) . ')', + 'data' => $line, + ]; + } + } + + if ( $imported_count > 0 ) echo '

Pomyślnie zaimportowano ' . esc_html( $imported_count ) . ' aktywności.

'; + if ( ! empty( $skipped_details ) ) { + echo '
'; + echo '

Pominięto ' . count( $skipped_details ) . ' wierszy z powodu błędów

'; + echo '
'; + echo ''; + foreach ( $skipped_details as $error ) { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + echo '
WierszPowód pominięciaDane wiersza
' . esc_html( $error['row'] ) . '' . esc_html( $error['reason'] ) . '' . esc_html( wp_trim_words( $error['data'], 25, '...' ) ) . '
'; + echo '
'; + } + if ( $imported_count === 0 && empty($skipped_details) && $row_number > 1) echo '

Dane CSV nie zawierały żadnych poprawnych wierszy do importu.

'; + elseif ($row_number === 1) echo '

Dane CSV były puste lub zawierały tylko nagłówek.

'; +} + function mystat_edit_activity_page() { global $wpdb; $activity_id = isset( $_GET['id'] ) ? intval( $_GET['id'] ) : 0; @@ -1200,8 +1511,17 @@ function mystat_render_history_table() { } } - // --- 2. POBIERANIE DANYCH (SELECT) --- - // Pobieramy ostatnie 10 wpisów, łącząc z tabelą kategorii, aby mieć nazwę i ikonę + // --- 2. USTAWIENIA PAGINACJI --- + $items_per_page = 20; // Ile wpisów na stronę + $current_page = isset( $_GET['paged'] ) ? max( 1, intval( $_GET['paged'] ) ) : 1; + $offset = ( $current_page - 1 ) * $items_per_page; + + // --- 3. POBIERANIE DANYCH (SELECT) --- + // Pobranie całkowitej liczby wpisów do paginacji + $total_items = $wpdb->get_var( "SELECT COUNT(id) FROM $table_activities" ); + $total_pages = ceil( $total_items / $items_per_page ); + + // Pobieramy wpisy dla bieżącej strony $sql = $wpdb->prepare(" SELECT a.*, c.name as category_name, c.icon, c.color, et.name as event_type_name, eq.name as equipment_name FROM $table_activities a @@ -1209,16 +1529,34 @@ function mystat_render_history_table() { LEFT JOIN {$wpdb->prefix}mystat_event_types et ON a.event_type_id = et.id LEFT JOIN {$wpdb->prefix}mystat_equipment eq ON a.equipment_id = eq.id ORDER BY a.date DESC, a.id DESC - LIMIT %d - ", 10); + LIMIT %d OFFSET %d + ", $items_per_page, $offset); $activities = $wpdb->get_results( $sql ); - // --- 3. WIDOK TABELI (HTML) --- + // --- 4. WIDOK TABELI (HTML) --- ?>
-

Ostatnie Aktywności

+

Historia Aktywności

+ 1 ) : ?> +
+
+ aktywności + add_query_arg( 'paged', '%#%' ), + 'format' => '', + 'total' => $total_pages, + 'current' => $current_page, + 'prev_text' => '« Poprzednia', + 'next_text' => 'Następna »', + ) ); + ?> +
+
+ + @@ -1238,13 +1576,11 @@ function mystat_render_history_table() { $_REQUEST['page'], // Zachowaj obecną stronę admina + // Generowanie URL-i akcji z zachowaniem paginacji + $delete_url = wp_nonce_url( add_query_arg( array( 'action' => 'mystat_delete', 'id' => $row->id, - '_wpnonce' => wp_create_nonce( 'mystat_delete_' . $row->id ) - ), admin_url( 'admin.php' ) ); + ) ), 'mystat_delete_' . $row->id ); $edit_url = add_query_arg( array( 'page' => 'mystat-edit-activity', @@ -1288,6 +1624,24 @@ function mystat_render_history_table() {
+ + 1 ) : ?> +
+
+ aktywności + add_query_arg( 'paged', '%#%' ), + 'format' => '', + 'total' => $total_pages, + 'current' => $current_page, + 'prev_text' => '« Poprzednia', + 'next_text' => 'Następna »', + ) ); + ?> +
+
+
title ); ?>

date ) ) ); ?>

- - - distance, 2, ',', ' '), 'km'); ?> - duration); ?> - avg_speed, 1, ',', ' '), 'km/h'); ?> - total_elevation_gain, 'm'); ?> - category_name); ?> - equipment_name); ?> - strava_url ) ) : ?> - - - -
StravaZobacz aktywność
+
+
+ + + distance, 2, ',', ' '), 'km'); ?> + duration); ?> + avg_speed, 1, ',', ' '), 'km/h'); ?> + total_elevation_gain, 'm'); ?> + +
+
+
+ + + category_name); ?> + equipment_name); ?> + strava_url ) ) : ?> + + + +
StravaZobacz aktywność
+
+
@@ -1527,6 +1891,8 @@ function mystat_single_activity_shortcode_handler( $atts ) { .mystat-single-activity-shortcode { border: 1px solid #eee; padding: 15px; margin-bottom: 1.5em; border-radius: 5px; background: #f9f9f9; } .mystat-single-activity-shortcode h4 { margin-top: 0; } .mystat-single-activity-shortcode p em { color: #777; font-size: 0.9em; } + .mystat-single-columns-container { display: flex; flex-wrap: wrap; gap: 30px; margin-bottom: 15px; } + .mystat-single-col { flex: 1; min-width: 240px; } .mystat-single-summary-table { width: 100%; border-collapse: collapse; } .mystat-single-summary-table th, .mystat-single-summary-table td { padding: 4px 0; border: none; text-align: left; vertical-align: top; } .mystat-single-summary-table th { font-weight: bold; width: 150px; }