607 lines
17 KiB
PHP
607 lines
17 KiB
PHP
<?php
|
|
/**
|
|
* iCal Import (subscribe to external .ics URL)
|
|
*
|
|
* Fetches .ics from external URL (e.g. Google Calendar public iCal link)
|
|
* and blocks dates in yacht availability.
|
|
*
|
|
* @package YachtBooking
|
|
*/
|
|
|
|
namespace YachtBooking\Integrations\ICal;
|
|
|
|
use YachtBooking\Availability;
|
|
use YachtBooking\Settings;
|
|
use YachtBooking\Yacht;
|
|
|
|
// Exit if accessed directly.
|
|
if ( ! defined( 'ABSPATH' ) ) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* ICal Import class
|
|
*/
|
|
class ICal_Import {
|
|
|
|
/**
|
|
* Booking source identifier for iCal imports (globalny — wspólny GCal,
|
|
* podział po prefiksie nazwy jachtu w SUMMARY).
|
|
*/
|
|
const GLOBAL_IMPORT_SOURCE = 'ical_import_global';
|
|
|
|
/**
|
|
* Booking source identifier dla trybu "wspólny kalendarz" (sync_mode=global).
|
|
*
|
|
* Eventy z tym source nie blokują dostępności jachtów (yacht_id=0) i są pokazywane
|
|
* tylko na widgecie zbiorczym "wszystkie jachty".
|
|
*/
|
|
const GLOBAL_CALENDAR_SOURCE = 'ical_global_calendar';
|
|
|
|
/**
|
|
* Separator między prefiksem nazwy jachtu a resztą tytułu eventu.
|
|
*
|
|
* Przykład SUMMARY: "Maja - Kowalski 5 osób" → prefix="Maja", reszta="Kowalski 5 osób".
|
|
*/
|
|
const SUMMARY_SEPARATOR = ' - ';
|
|
|
|
/**
|
|
* Register cron actions
|
|
*/
|
|
public static function register() {
|
|
add_action( 'yacht_booking_ical_global_import', array( __CLASS__, 'run_global_import' ) );
|
|
}
|
|
|
|
/**
|
|
* Setup cron schedule
|
|
*/
|
|
public static function setup_cron() {
|
|
if ( ! wp_next_scheduled( 'yacht_booking_ical_global_import' ) ) {
|
|
wp_schedule_event( time(), 'hourly', 'yacht_booking_ical_global_import' );
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear cron
|
|
*/
|
|
public static function clear_cron() {
|
|
wp_clear_scheduled_hook( 'yacht_booking_ical_global_import' );
|
|
}
|
|
|
|
/**
|
|
* Globalny iCal import — pobiera jeden URL kalendarza Google z ustawień
|
|
* globalnych, parsuje eventy i przypisuje je do jachtów po prefiksie w SUMMARY.
|
|
*
|
|
* Format SUMMARY: "{nazwa_jachtu_lub_alias} - {opis}".
|
|
* Eventy bez separatora lub bez dopasowanego jachtu są ignorowane (logowane).
|
|
*
|
|
* @return bool True przy sukcesie HTTP, false przy błędzie pobrania/braku URL.
|
|
*/
|
|
public static function run_global_import() {
|
|
$url = get_option( 'yacht_booking_global_ical_import_url', '' );
|
|
$url = is_string( $url ) ? trim( $url ) : '';
|
|
|
|
if ( empty( $url ) ) {
|
|
return false;
|
|
}
|
|
|
|
$response = wp_remote_get(
|
|
$url,
|
|
array(
|
|
'timeout' => 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 ) );
|
|
}
|
|
}
|
|
}
|