30, 'sslverify' => true, ) ); if ( is_wp_error( $response ) ) { self::log( sprintf( 'Global iCal fetch failed: %s', $response->get_error_message() ), 'error' ); return false; } $body = wp_remote_retrieve_body( $response ); if ( empty( $body ) ) { self::log( 'Global iCal: empty response body', 'error' ); return false; } $events = self::parse_ics( $body ); $mode = Settings::get_ical_sync_mode(); if ( 'global' === $mode ) { self::run_global_calendar_mode( $events ); } else { self::run_per_yacht_mode( $events ); } update_option( 'yacht_booking_global_ical_last_import', current_time( 'mysql' ) ); return true; } /** * Tryb per-jacht: dopasowanie po prefiksie SUMMARY, wpisy do availability. * * @param array $events Sparsowane eventy iCal. */ protected static function run_per_yacht_mode( $events ) { $yacht_map = self::build_yacht_lookup_map(); if ( empty( $yacht_map ) ) { self::log( 'Per-yacht iCal: no yachts in DB — nothing to match', 'error' ); return; } $existing_map = self::get_existing_global_import_map(); $seen_uids = array(); foreach ( $events as $event ) { if ( empty( $event['uid'] ) || empty( $event['start'] ) || empty( $event['end'] ) ) { continue; } // Skip past events. if ( strtotime( $event['end'] ) < time() ) { continue; } $summary = isset( $event['summary'] ) ? (string) $event['summary'] : ''; $yacht_id = self::match_yacht_by_prefix( $summary, $yacht_map ); if ( ! $yacht_id ) { self::log( sprintf( 'Per-yacht iCal: skip event "%s" — no yacht match for prefix', $summary ) ); continue; } $seen_uids[] = $event['uid']; $existing_id = isset( $existing_map[ $event['uid'] ] ) ? (int) $existing_map[ $event['uid'] ] : 0; $booking_id = self::upsert_global_booking( $yacht_id, $event, $existing_id ); if ( ! $booking_id ) { self::log( sprintf( 'Per-yacht iCal: failed to upsert event %s', $event['uid'] ), 'error' ); } } // Stale cleanup — usuń bookingi których UID nie ma już w feedzie. // Ograniczone do GLOBAL_IMPORT_SOURCE (per-yacht) — nie tyka GLOBAL_CALENDAR_SOURCE. foreach ( $existing_map as $uid => $booking_id ) { if ( ! in_array( $uid, $seen_uids, true ) ) { \YachtBooking\Availability::clear_booking_availability( $booking_id ); wp_delete_post( $booking_id, true ); } } } /** * Tryb global: wszystkie eventy (bez filtrowania) zapisane jako wspólne wydarzenia * kalendarza. yacht_id=0, brak wpisów do wp_yacht_availability. * * @param array $events Sparsowane eventy iCal. */ protected static function run_global_calendar_mode( $events ) { $existing_map = self::get_existing_global_calendar_map(); $seen_uids = array(); $imported = 0; $updated = 0; foreach ( $events as $event ) { if ( empty( $event['uid'] ) || empty( $event['start'] ) || empty( $event['end'] ) ) { continue; } // Skip past events. if ( strtotime( $event['end'] ) < time() ) { continue; } $seen_uids[] = $event['uid']; $existing_id = isset( $existing_map[ $event['uid'] ] ) ? (int) $existing_map[ $event['uid'] ] : 0; $booking_id = self::upsert_global_calendar_event( $event, $existing_id ); if ( ! $booking_id ) { self::log( sprintf( 'Global calendar iCal: failed to upsert event %s', $event['uid'] ), 'error' ); continue; } if ( $existing_id > 0 ) { $updated++; } else { $imported++; } } // Stale cleanup — usuń tylko eventy z GLOBAL_CALENDAR_SOURCE których UID brakuje. $deleted = 0; foreach ( $existing_map as $uid => $booking_id ) { if ( ! in_array( $uid, $seen_uids, true ) ) { wp_delete_post( $booking_id, true ); $deleted++; } } self::log( sprintf( 'Global calendar iCal: imported=%d, updated=%d, deleted=%d', $imported, $updated, $deleted ) ); } /** * Buduje mapę: lowercase(alias|post_title) => yacht_id. * * Alias (`_yacht_gcal_alias`) ma priorytet nad post_title. Klucze są * znormalizowane przez mb_strtolower + trim, dla matchowania case-insensitive * i niezależnego od białych znaków. * * @return array */ protected static function build_yacht_lookup_map() { $yachts = get_posts( array( 'post_type' => 'yacht', 'post_status' => 'publish', 'posts_per_page' => -1, ) ); $map = array(); foreach ( $yachts as $yacht ) { $alias = \YachtBooking\Yacht::get_gcal_alias( $yacht->ID ); $key = '' !== trim( (string) $alias ) ? $alias : $yacht->post_title; $key = mb_strtolower( trim( (string) $key ) ); if ( '' === $key ) { continue; } // Nie nadpisuj — w razie kolizji wygrywa pierwszy. Kolizje są // wpisem do logu w czasie matchowania (nie tutaj — bezgłośnie pierwszy). if ( ! isset( $map[ $key ] ) ) { $map[ $key ] = (int) $yacht->ID; } } return $map; } /** * Wyciąga prefiks (przed pierwszym " - ") z SUMMARY i dopasowuje do jachtu * w mapie (case-insensitive). * * @param string $summary SUMMARY eventu. * @param array $yacht_map Mapa lowercase(klucz) => yacht_id. * @return int 0 gdy brak dopasowania. */ protected static function match_yacht_by_prefix( $summary, $yacht_map ) { if ( '' === $summary ) { return 0; } $pos = mb_strpos( $summary, self::SUMMARY_SEPARATOR ); if ( false === $pos ) { return 0; } $prefix = mb_substr( $summary, 0, $pos ); $prefix = mb_strtolower( trim( $prefix ) ); if ( '' === $prefix ) { return 0; } return isset( $yacht_map[ $prefix ] ) ? (int) $yacht_map[ $prefix ] : 0; } /** * Zwraca mapę istniejących globalnych importów: uid => booking_id. * * @return array */ protected static function get_existing_global_import_map() { $bookings = get_posts( array( 'post_type' => 'yacht_booking', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'meta_query' => array( array( 'key' => '_booking_source', 'value' => self::GLOBAL_IMPORT_SOURCE, ), ), ) ); $map = array(); foreach ( $bookings as $booking_id ) { $uid = get_post_meta( $booking_id, '_ical_event_uid', true ); if ( $uid ) { $map[ $uid ] = (int) $booking_id; } } return $map; } /** * Tworzy lub aktualizuje booking placeholder z globalnego importu. * * @param int $yacht_id Yacht ID. * @param array $event Parsed event data. * @param int $existing_id Existing booking ID (0 dla nowego). * @return int|false */ protected static function upsert_global_booking( $yacht_id, $event, $existing_id = 0 ) { $summary = ! empty( $event['summary'] ) ? sanitize_text_field( $event['summary'] ) : __( 'Blokada Google Calendar', 'yacht-booking' ); $start_date = $event['start']; $end_date = $event['end']; $post_data = array( 'post_type' => 'yacht_booking', 'post_status' => 'publish', 'post_title' => sprintf( /* translators: %s: event summary */ __( 'GCal: %s', 'yacht-booking' ), $summary ), ); if ( $existing_id > 0 ) { $post_data['ID'] = $existing_id; $booking_id = wp_update_post( $post_data, true ); } else { $booking_id = wp_insert_post( $post_data, true ); } if ( is_wp_error( $booking_id ) || ! $booking_id ) { return false; } update_post_meta( $booking_id, '_booking_yacht_id', (int) $yacht_id ); update_post_meta( $booking_id, '_booking_start_date', $start_date ); update_post_meta( $booking_id, '_booking_end_date', $end_date ); update_post_meta( $booking_id, '_booking_status', 'confirmed' ); update_post_meta( $booking_id, '_booking_customer_name', __( 'Google Calendar (import)', 'yacht-booking' ) ); update_post_meta( $booking_id, '_booking_customer_email', '' ); update_post_meta( $booking_id, '_booking_customer_phone', '' ); update_post_meta( $booking_id, '_booking_total_price', 0 ); update_post_meta( $booking_id, '_booking_source', self::GLOBAL_IMPORT_SOURCE ); update_post_meta( $booking_id, '_ical_event_uid', $event['uid'] ); update_post_meta( $booking_id, '_booking_notes', $summary ); \YachtBooking\Availability::clear_booking_availability( $booking_id ); \YachtBooking\Availability::mark_as_booked( $yacht_id, $start_date, $end_date, $booking_id ); return (int) $booking_id; } /** * Mapa istniejących eventów GLOBAL_CALENDAR_SOURCE (tryb global): uid => booking_id. * * @return array */ protected static function get_existing_global_calendar_map() { $bookings = get_posts( array( 'post_type' => 'yacht_booking', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'meta_query' => array( array( 'key' => '_booking_source', 'value' => self::GLOBAL_CALENDAR_SOURCE, ), ), ) ); $map = array(); foreach ( $bookings as $booking_id ) { $uid = get_post_meta( $booking_id, '_ical_event_uid', true ); if ( $uid ) { $map[ $uid ] = (int) $booking_id; } } return $map; } /** * Tworzy lub aktualizuje wspólne wydarzenie kalendarza (sync_mode=global). * * yacht_id=0, brak wpisów do availability — event tylko informacyjny na widgecie zbiorczym. * * @param array $event Parsed event data. * @param int $existing_id Existing booking ID (0 dla nowego). * @return int|false */ protected static function upsert_global_calendar_event( $event, $existing_id = 0 ) { $summary = ! empty( $event['summary'] ) ? sanitize_text_field( $event['summary'] ) : __( 'Wydarzenie kalendarza', 'yacht-booking' ); $post_data = array( 'post_type' => 'yacht_booking', 'post_status' => 'publish', 'post_title' => sprintf( /* translators: %s: event summary */ __( 'GCal (wspólny): %s', 'yacht-booking' ), $summary ), ); if ( $existing_id > 0 ) { $post_data['ID'] = $existing_id; $booking_id = wp_update_post( $post_data, true ); } else { $booking_id = wp_insert_post( $post_data, true ); } if ( is_wp_error( $booking_id ) || ! $booking_id ) { return false; } update_post_meta( $booking_id, '_booking_yacht_id', 0 ); update_post_meta( $booking_id, '_booking_start_date', $event['start'] ); update_post_meta( $booking_id, '_booking_end_date', $event['end'] ); update_post_meta( $booking_id, '_booking_status', 'confirmed' ); update_post_meta( $booking_id, '_booking_customer_name', __( 'Google Calendar (kalendarz wspólny)', 'yacht-booking' ) ); update_post_meta( $booking_id, '_booking_customer_email', '' ); update_post_meta( $booking_id, '_booking_customer_phone', '' ); update_post_meta( $booking_id, '_booking_total_price', 0 ); update_post_meta( $booking_id, '_booking_source', self::GLOBAL_CALENDAR_SOURCE ); update_post_meta( $booking_id, '_ical_event_uid', $event['uid'] ); update_post_meta( $booking_id, '_booking_notes', $summary ); // CELOWO BEZ Availability::mark_as_booked() — wspólne eventy nie blokują dostępności jachtów. // Defensywnie: jeśli kiedyś existing_id miał wpisy availability (np. po przełączeniu trybu), // usuń je, by nie zostawiały śmieci. if ( $existing_id > 0 ) { \YachtBooking\Availability::clear_booking_availability( $existing_id ); } return (int) $booking_id; } /** * Parse .ics content into array of events. * * @param string $ics_content Raw .ics content. * @return array */ protected static function parse_ics( $ics_content ) { $events = array(); $lines = preg_split( '/\r\n|\r|\n/', $ics_content ); $in_event = false; $event = array(); // Unfold lines (RFC 5545: lines starting with space/tab are continuations) $unfolded = array(); foreach ( $lines as $line ) { if ( strlen( $line ) > 0 && ( ' ' === $line[0] || "\t" === $line[0] ) && count( $unfolded ) > 0 ) { $unfolded[ count( $unfolded ) - 1 ] .= substr( $line, 1 ); } else { $unfolded[] = $line; } } foreach ( $unfolded as $line ) { $line = trim( $line ); if ( 'BEGIN:VEVENT' === $line ) { $in_event = true; $event = array( 'uid' => '', 'summary' => '', 'start' => '', 'end' => '', ); continue; } if ( 'END:VEVENT' === $line ) { $in_event = false; if ( ! empty( $event['uid'] ) ) { $events[] = $event; } continue; } if ( ! $in_event ) { continue; } // Parse property if ( self::line_starts_with( $line, 'UID:' ) ) { $event['uid'] = self::extract_value( $line ); } elseif ( self::line_starts_with( $line, 'SUMMARY' ) ) { $event['summary'] = self::unescape_ical( self::extract_value( $line ) ); } elseif ( self::line_starts_with( $line, 'DTSTART' ) ) { $event['start'] = self::parse_ical_date( $line ); } elseif ( self::line_starts_with( $line, 'DTEND' ) ) { $event['end'] = self::parse_ical_date( $line ); } } return $events; } /** * Check if line starts with prefix (case-insensitive for property name). * * @param string $line Line. * @param string $prefix Prefix. * @return bool */ private static function line_starts_with( $line, $prefix ) { return 0 === strncasecmp( $line, $prefix, strlen( $prefix ) ); } /** * Extract value from iCal line (handles parameters like DTSTART;VALUE=DATE:20260315). * * @param string $line Line. * @return string */ private static function extract_value( $line ) { $pos = strpos( $line, ':' ); return false !== $pos ? substr( $line, $pos + 1 ) : ''; } /** * Parse iCal date to Y-m-d format. * * @param string $line Full iCal line. * @return string */ private static function parse_ical_date( $line ) { $value = self::extract_value( $line ); $value = trim( $value ); // All-day: 20260315 if ( preg_match( '/^(\d{4})(\d{2})(\d{2})$/', $value, $m ) ) { return $m[1] . '-' . $m[2] . '-' . $m[3]; } // DateTime: 20260315T100000Z or 20260315T100000 if ( preg_match( '/^(\d{4})(\d{2})(\d{2})T/', $value ) ) { $ts = strtotime( $value ); return false !== $ts ? gmdate( 'Y-m-d', $ts ) : ''; } return ''; } /** * Unescape iCal text. * * @param string $text Text. * @return string */ private static function unescape_ical( $text ) { $text = str_replace( '\\n', "\n", $text ); $text = str_replace( '\\,', ',', $text ); $text = str_replace( '\\;', ';', $text ); $text = str_replace( '\\\\', '\\', $text ); return $text; } /** * Log message. * * @param string $message Message. * @param string $type Type (info|error). */ private static function log( $message, $type = 'info' ) { if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) { $prefix = 'error' === $type ? 'ERROR' : 'INFO'; error_log( sprintf( '[Yacht Booking - iCal] [%s] %s', $prefix, $message ) ); } } }