first commit

This commit is contained in:
Roman Pyrih
2026-04-21 15:48:41 +02:00
commit 7483681901
10216 changed files with 3236626 additions and 0 deletions

View File

@@ -0,0 +1,521 @@
<?php
/**
* Google Calendar Service
*
* @package YachtBooking
*/
namespace YachtBooking\Integrations\GoogleCalendar;
use YachtBooking\Booking;
use YachtBooking\Availability;
// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Google Calendar Service class
*/
class GCal_Service {
/**
* Google Calendar API base URL
*/
const API_BASE = 'https://www.googleapis.com/calendar/v3';
/**
* Booking source meta value for imported Google events.
*/
const EXTERNAL_BOOKING_SOURCE = 'google_calendar_external';
/**
* Create event in Google Calendar
*
* @param int $booking_id Booking post ID.
* @return string|false Event ID or false on error.
*/
public static function create_event( $booking_id ) {
$access_token = OAuth_Handler::get_access_token();
$calendar_id = self::get_calendar_id();
if ( ! $access_token || ! $calendar_id ) {
return false;
}
// Get booking data
$yacht_id = Booking::get_yacht_id( $booking_id );
$yacht = get_post( $yacht_id );
$start_date = Booking::get_start_date( $booking_id );
$end_date = Booking::get_end_date( $booking_id );
$customer_name = Booking::get_customer_name( $booking_id );
$customer_email = Booking::get_customer_email( $booking_id );
$customer_phone = Booking::get_customer_phone( $booking_id );
$status = Booking::get_status( $booking_id );
// Prepare event data
$event = array(
'summary' => sprintf(
/* translators: 1: yacht name, 2: customer name */
__( 'Rezerwacja: %1$s - %2$s', 'yacht-booking' ),
$yacht ? $yacht->post_title : __( 'Jacht', 'yacht-booking' ),
$customer_name
),
'description' => sprintf(
"Booking ID: #%d\n\nKlient: %s\nEmail: %s\nTelefon: %s\nStatus: %s\n\nZarządzanie: %s",
$booking_id,
$customer_name,
$customer_email,
$customer_phone,
$status,
admin_url( 'admin.php?page=yacht-bookings-list&booking_id=' . $booking_id )
),
'start' => array(
'date' => $start_date, // All-day event
),
'end' => array(
'date' => date( 'Y-m-d', strtotime( $end_date . ' +1 day' ) ), // Exclusive end date
),
'colorId' => 'confirmed' === $status ? '9' : '11', // Blue for confirmed, red for pending
);
$response = wp_remote_post(
self::API_BASE . '/calendars/' . urlencode( $calendar_id ) . '/events',
array(
'headers' => array(
'Authorization' => 'Bearer ' . $access_token,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode( $event ),
)
);
if ( is_wp_error( $response ) ) {
self::log_error( 'Create event failed: ' . $response->get_error_message() );
return false;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( isset( $body['id'] ) ) {
// Save Google Event ID to booking meta
update_post_meta( $booking_id, '_gcal_event_id', $body['id'] );
return $body['id'];
}
self::log_error( 'Create event failed: ' . wp_remote_retrieve_body( $response ) );
return false;
}
/**
* Update event in Google Calendar
*
* @param int $booking_id Booking post ID.
* @return bool
*/
public static function update_event( $booking_id ) {
$access_token = OAuth_Handler::get_access_token();
$calendar_id = self::get_calendar_id();
$event_id = get_post_meta( $booking_id, '_gcal_event_id', true );
if ( ! $access_token || ! $calendar_id || ! $event_id ) {
return false;
}
// Get booking data
$status = Booking::get_status( $booking_id );
// Update only color based on status
$event = array(
'colorId' => 'confirmed' === $status ? '9' : ( 'cancelled' === $status ? '8' : '11' ),
);
$response = wp_remote_request(
self::API_BASE . '/calendars/' . urlencode( $calendar_id ) . '/events/' . urlencode( $event_id ),
array(
'method' => 'PATCH',
'headers' => array(
'Authorization' => 'Bearer ' . $access_token,
'Content-Type' => 'application/json',
),
'body' => wp_json_encode( $event ),
)
);
if ( is_wp_error( $response ) ) {
self::log_error( 'Update event failed: ' . $response->get_error_message() );
return false;
}
return true;
}
/**
* Delete event from Google Calendar
*
* @param int $booking_id Booking post ID.
* @return bool
*/
public static function delete_event( $booking_id ) {
$access_token = OAuth_Handler::get_access_token();
$calendar_id = self::get_calendar_id();
$event_id = get_post_meta( $booking_id, '_gcal_event_id', true );
if ( ! $access_token || ! $calendar_id || ! $event_id ) {
return false;
}
$response = wp_remote_request(
self::API_BASE . '/calendars/' . urlencode( $calendar_id ) . '/events/' . urlencode( $event_id ),
array(
'method' => 'DELETE',
'headers' => array(
'Authorization' => 'Bearer ' . $access_token,
),
)
);
if ( is_wp_error( $response ) ) {
self::log_error( 'Delete event failed: ' . $response->get_error_message() );
return false;
}
// Remove event ID from meta
delete_post_meta( $booking_id, '_gcal_event_id' );
return true;
}
/**
* Sync events from Google Calendar to WordPress
*
* @param int $yacht_id Yacht post ID.
* @return bool
*/
public static function sync_from_gcal( $yacht_id ) {
$access_token = OAuth_Handler::get_access_token();
$calendar_id = self::get_calendar_id();
if ( ! $access_token || ! $calendar_id ) {
return false;
}
// Fetch events from now to +1 year
$time_min = gmdate( 'Y-m-d\TH:i:s\Z' );
$time_max = gmdate( 'Y-m-d\TH:i:s\Z', strtotime( '+1 year' ) );
$response = wp_remote_get(
add_query_arg(
array(
'timeMin' => $time_min,
'timeMax' => $time_max,
'singleEvents' => 'true',
'orderBy' => 'startTime',
),
self::API_BASE . '/calendars/' . urlencode( $calendar_id ) . '/events'
),
array(
'headers' => array(
'Authorization' => 'Bearer ' . $access_token,
),
)
);
if ( is_wp_error( $response ) ) {
self::log_error( 'Sync from GCal failed: ' . $response->get_error_message() );
return false;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
$events = isset( $body['items'] ) ? $body['items'] : array();
// Internal bookings (created in WordPress and pushed to Google) should not be re-imported.
$internal_event_ids = self::get_internal_booking_event_ids();
$external_booking_map = self::get_external_booking_map( $yacht_id );
$seen_external_ids = array();
foreach ( $events as $event ) {
if ( empty( $event['id'] ) ) {
continue;
}
// Skip events already linked to internal WordPress bookings.
if ( in_array( $event['id'], $internal_event_ids, true ) ) {
continue;
}
$range = self::get_blocked_range_from_event( $event );
if ( ! $range ) {
continue;
}
$booking_id = isset( $external_booking_map[ $event['id'] ] ) ? (int) $external_booking_map[ $event['id'] ] : 0;
$booking_id = self::upsert_external_booking( $yacht_id, $event, $range, $booking_id );
if ( $booking_id ) {
$seen_external_ids[] = $event['id'];
}
}
// Remove stale imported placeholders (event removed from Google Calendar).
foreach ( $external_booking_map as $event_id => $booking_id ) {
if ( ! in_array( $event_id, $seen_external_ids, true ) ) {
Availability::clear_booking_availability( $booking_id );
// Prevent before_delete_post Google delete call for already-removed external events.
delete_post_meta( $booking_id, '_gcal_event_id' );
wp_delete_post( $booking_id, true );
}
}
return true;
}
/**
* Get calendar ID
*
* @return string|false Calendar ID or false.
*/
public static function get_calendar_id() {
$calendar_id = get_option( 'yacht_booking_gcal_calendar_id' );
// Default to primary calendar if not set
return $calendar_id ? $calendar_id : 'primary';
}
/**
* Set calendar ID
*
* @param string $calendar_id Calendar ID.
* @return bool
*/
public static function set_calendar_id( $calendar_id ) {
return update_option( 'yacht_booking_gcal_calendar_id', $calendar_id );
}
/**
* Get list of user's calendars
*
* @return array|false Array of calendars or false on error.
*/
public static function get_calendar_list() {
$access_token = OAuth_Handler::get_access_token();
if ( ! $access_token ) {
return false;
}
$response = wp_remote_get(
self::API_BASE . '/users/me/calendarList',
array(
'headers' => array(
'Authorization' => 'Bearer ' . $access_token,
),
)
);
if ( is_wp_error( $response ) ) {
return false;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return isset( $body['items'] ) ? $body['items'] : false;
}
/**
* Log error
*
* @param string $message Error message.
*/
private static function log_error( $message ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( '[Yacht Booking - GCal] ' . $message );
}
}
/**
* Map Google Calendar event to blocked date range.
*
* IMPORTANT: Availability::mark_as_blocked() expects end date to be exclusive.
*
* @param array $event Google Calendar event payload.
* @return array|false
*/
private static function get_blocked_range_from_event( $event ) {
// Skip cancelled events.
if ( isset( $event['status'] ) && 'cancelled' === $event['status'] ) {
return false;
}
// Skip "free" events (Show as: Available).
if ( isset( $event['transparency'] ) && 'transparent' === $event['transparency'] ) {
return false;
}
// All-day events: end date from Google is already exclusive.
if ( isset( $event['start']['date'] ) && isset( $event['end']['date'] ) ) {
$start_date = $event['start']['date'];
$end_date = $event['end']['date'];
if ( strtotime( $end_date ) <= strtotime( $start_date ) ) {
return false;
}
return array(
'start_date' => $start_date,
'end_date' => $end_date,
);
}
// Timed events: convert to full occupied-day range.
if ( isset( $event['start']['dateTime'] ) && isset( $event['end']['dateTime'] ) ) {
$start_ts = strtotime( $event['start']['dateTime'] );
$end_ts = strtotime( $event['end']['dateTime'] );
if ( false === $start_ts || false === $end_ts || $end_ts <= $start_ts ) {
return false;
}
$start_date = gmdate( 'Y-m-d', $start_ts );
// Last occupied second to avoid midnight edge-cases.
$last_occupied_ts = $end_ts - 1;
$last_occupied = gmdate( 'Y-m-d', $last_occupied_ts );
$end_date = gmdate( 'Y-m-d', strtotime( $last_occupied . ' +1 day' ) );
if ( strtotime( $end_date ) <= strtotime( $start_date ) ) {
$end_date = gmdate( 'Y-m-d', strtotime( $start_date . ' +1 day' ) );
}
return array(
'start_date' => $start_date,
'end_date' => $end_date,
);
}
return false;
}
/**
* Get Google event IDs that belong to internal bookings.
*
* @return array
*/
private static function get_internal_booking_event_ids() {
global $wpdb;
$sql = $wpdb->prepare(
"SELECT event_pm.meta_value
FROM {$wpdb->postmeta} event_pm
INNER JOIN {$wpdb->posts} p ON p.ID = event_pm.post_id
LEFT JOIN {$wpdb->postmeta} source_pm
ON source_pm.post_id = event_pm.post_id
AND source_pm.meta_key = '_booking_source'
WHERE event_pm.meta_key = '_gcal_event_id'
AND p.post_type = 'yacht_booking'
AND p.post_status = 'publish'
AND (source_pm.meta_value IS NULL OR source_pm.meta_value != %s)",
self::EXTERNAL_BOOKING_SOURCE
);
$ids = $wpdb->get_col( $sql );
return is_array( $ids ) ? $ids : array();
}
/**
* Get imported external booking map for yacht: event_id => booking_id.
*
* @param int $yacht_id Yacht ID.
* @return array
*/
private static function get_external_booking_map( $yacht_id ) {
$bookings = get_posts(
array(
'post_type' => 'yacht_booking',
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_booking_source',
'value' => self::EXTERNAL_BOOKING_SOURCE,
),
array(
'key' => '_booking_yacht_id',
'value' => (int) $yacht_id,
),
),
)
);
$map = array();
foreach ( $bookings as $booking_id ) {
$event_id = get_post_meta( $booking_id, '_gcal_event_id', true );
if ( ! empty( $event_id ) ) {
$map[ $event_id ] = (int) $booking_id;
}
}
return $map;
}
/**
* Create or update imported external booking placeholder.
*
* @param int $yacht_id Yacht ID.
* @param array $event Google event payload.
* @param array $range Date range (exclusive end).
* @param int $existing_booking_id Existing booking ID.
* @return int|false
*/
private static function upsert_external_booking( $yacht_id, $event, $range, $existing_booking_id = 0 ) {
$summary = isset( $event['summary'] ) && '' !== trim( $event['summary'] ) ? sanitize_text_field( $event['summary'] ) : __( 'Wydarzenie z Google Calendar', 'yacht-booking' );
$start_date = $range['start_date'];
$end_date = $range['end_date'];
$event_id = $event['id'];
$event_url = isset( $event['htmlLink'] ) ? esc_url_raw( $event['htmlLink'] ) : '';
$post_data = array(
'post_type' => 'yacht_booking',
'post_status' => 'publish',
'post_title' => sprintf(
/* translators: %s: Google event summary */
__( 'Blokada Google Calendar: %s', 'yacht-booking' ),
$summary
),
);
if ( $existing_booking_id > 0 ) {
$post_data['ID'] = $existing_booking_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 ) {
self::log_error( 'Failed to upsert external booking for event: ' . $event_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::EXTERNAL_BOOKING_SOURCE );
update_post_meta( $booking_id, '_gcal_event_id', $event_id );
update_post_meta( $booking_id, '_booking_notes', $event_url );
// Rebuild availability tied to this imported booking.
Availability::clear_booking_availability( $booking_id );
Availability::mark_as_booked( $yacht_id, $start_date, $end_date, $booking_id );
return (int) $booking_id;
}
}

View File

@@ -0,0 +1,270 @@
<?php
/**
* Google Calendar OAuth Handler
*
* @package YachtBooking
*/
namespace YachtBooking\Integrations\GoogleCalendar;
// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* OAuth Handler class
*/
class OAuth_Handler {
/**
* Google OAuth endpoints
*/
const AUTH_URL = 'https://accounts.google.com/o/oauth2/v2/auth';
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const SCOPE = 'https://www.googleapis.com/auth/calendar';
/**
* Get OAuth authorization URL
*
* @return string|false Authorization URL or false on error.
*/
public static function get_auth_url() {
$credentials = self::get_credentials();
if ( ! $credentials ) {
return false;
}
// Force production URL for Google OAuth
$redirect_uri = 'https://jachty.pagedev.pl/wp-admin/admin.php?page=yacht-bookings-settings&tab=google-calendar&gcal_callback=1';
// Build URL manually to ensure proper encoding
$params = array(
'client_id' => $credentials['client_id'],
'redirect_uri' => $redirect_uri,
'response_type' => 'code',
'scope' => self::SCOPE,
'access_type' => 'offline',
'prompt' => 'consent',
);
// Build query string with proper URL encoding
$query = http_build_query( $params, '', '&' );
return self::AUTH_URL . '?' . $query;
}
/**
* Exchange authorization code for access token
*
* @param string $code Authorization code.
* @return array|false Token data or false on error.
*/
public static function authenticate( $code ) {
$credentials = self::get_credentials();
if ( ! $credentials ) {
return false;
}
// Force production URL for Google OAuth
$redirect_uri = 'https://jachty.pagedev.pl/wp-admin/admin.php?page=yacht-bookings-settings&tab=google-calendar&gcal_callback=1';
$response = wp_remote_post(
self::TOKEN_URL,
array(
'body' => array(
'code' => $code,
'client_id' => $credentials['client_id'],
'client_secret' => $credentials['client_secret'],
'redirect_uri' => $redirect_uri,
'grant_type' => 'authorization_code',
),
)
);
if ( is_wp_error( $response ) ) {
return false;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( isset( $body['access_token'] ) ) {
// Save tokens
self::save_tokens( $body );
return $body;
}
return false;
}
/**
* Get access token (refresh if expired)
*
* @return string|false Access token or false on error.
*/
public static function get_access_token() {
$tokens = get_option( 'yacht_booking_gcal_tokens' );
if ( ! $tokens || ! isset( $tokens['access_token'] ) ) {
return false;
}
// Check if token is expired
$expires_at = isset( $tokens['expires_at'] ) ? $tokens['expires_at'] : 0;
if ( time() >= $expires_at ) {
// Token expired, refresh it
$new_tokens = self::refresh_access_token();
if ( ! $new_tokens ) {
return false;
}
return $new_tokens['access_token'];
}
return $tokens['access_token'];
}
/**
* Refresh access token using refresh token
*
* @return array|false New token data or false on error.
*/
public static function refresh_access_token() {
$credentials = self::get_credentials();
$tokens = get_option( 'yacht_booking_gcal_tokens' );
if ( ! $credentials || ! $tokens || ! isset( $tokens['refresh_token'] ) ) {
return false;
}
$response = wp_remote_post(
self::TOKEN_URL,
array(
'body' => array(
'client_id' => $credentials['client_id'],
'client_secret' => $credentials['client_secret'],
'refresh_token' => $tokens['refresh_token'],
'grant_type' => 'refresh_token',
),
)
);
if ( is_wp_error( $response ) ) {
return false;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
if ( isset( $body['access_token'] ) ) {
// Preserve refresh_token (not always returned)
if ( ! isset( $body['refresh_token'] ) && isset( $tokens['refresh_token'] ) ) {
$body['refresh_token'] = $tokens['refresh_token'];
}
self::save_tokens( $body );
return $body;
}
return false;
}
/**
* Save OAuth credentials
*
* @param array $credentials Credentials array.
* @return bool
*/
public static function save_credentials( $credentials ) {
if ( ! isset( $credentials['client_id'] ) || ! isset( $credentials['client_secret'] ) ) {
return false;
}
return update_option( 'yacht_booking_gcal_credentials', $credentials );
}
/**
* Get saved credentials
*
* @return array|false Credentials or false.
*/
public static function get_credentials() {
$credentials = get_option( 'yacht_booking_gcal_credentials' );
if ( ! $credentials || ! isset( $credentials['client_id'] ) || ! isset( $credentials['client_secret'] ) ) {
return false;
}
return $credentials;
}
/**
* Save OAuth tokens
*
* @param array $tokens Token data.
*/
private static function save_tokens( $tokens ) {
// Calculate expiry time
if ( isset( $tokens['expires_in'] ) ) {
$tokens['expires_at'] = time() + (int) $tokens['expires_in'];
}
update_option( 'yacht_booking_gcal_tokens', $tokens );
}
/**
* Clear all Google Calendar data (disconnect)
*
* @return bool
*/
public static function disconnect() {
delete_option( 'yacht_booking_gcal_credentials' );
delete_option( 'yacht_booking_gcal_tokens' );
delete_option( 'yacht_booking_gcal_calendar_id' );
return true;
}
/**
* Check if connected to Google Calendar
*
* @return bool
*/
public static function is_connected() {
$tokens = get_option( 'yacht_booking_gcal_tokens' );
return ! empty( $tokens ) && isset( $tokens['access_token'] );
}
/**
* Get connected email (from token info)
*
* @return string|false Email or false.
*/
public static function get_connected_email() {
$access_token = self::get_access_token();
if ( ! $access_token ) {
return false;
}
$response = wp_remote_get(
'https://www.googleapis.com/oauth2/v2/userinfo',
array(
'headers' => array(
'Authorization' => 'Bearer ' . $access_token,
),
)
);
if ( is_wp_error( $response ) ) {
return false;
}
$body = json_decode( wp_remote_retrieve_body( $response ), true );
return isset( $body['email'] ) ? $body['email'] : false;
}
}

View File

@@ -0,0 +1,377 @@
<?php
/**
* Google Calendar Sync Controller
*
* @package YachtBooking
*/
namespace YachtBooking\Integrations\GoogleCalendar;
// Exit if accessed directly
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* Sync Controller class
*
* Handles automatic synchronization between WordPress bookings and Google Calendar
*/
class Sync_Controller {
/**
* Instance
*
* @var Sync_Controller
*/
private static $instance;
/**
* Get instance
*
* @return Sync_Controller
*/
public static function get_instance() {
if ( null === self::$instance ) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
// Hook into booking events
add_action( 'yacht_booking_created', array( $this, 'on_booking_created' ), 20, 1 );
add_action( 'yacht_booking_status_changed', array( $this, 'on_booking_status_changed' ), 20, 2 );
add_action( 'before_delete_post', array( $this, 'on_booking_deleted' ), 10, 1 );
// AJAX handler for manual sync
add_action( 'wp_ajax_yacht_booking_manual_sync', array( $this, 'ajax_manual_sync' ) );
}
/**
* Handle booking created
*
* @param int $booking_id Booking post ID.
*/
public function on_booking_created( $booking_id ) {
// Check if connected
if ( ! OAuth_Handler::is_connected() ) {
return;
}
// Schedule async sync (avoid blocking the request)
wp_schedule_single_event(
time(),
'yacht_booking_sync_to_gcal',
array( $booking_id )
);
}
/**
* Handle booking status changed
*
* @param int $booking_id Booking post ID.
* @param string $new_status New status.
*/
public function on_booking_status_changed( $booking_id, $new_status ) {
// Check if connected
if ( ! OAuth_Handler::is_connected() ) {
return;
}
$event_id = get_post_meta( $booking_id, '_gcal_event_id', true );
if ( 'cancelled' === $new_status && $event_id ) {
// Delete event from Google Calendar
wp_schedule_single_event(
time(),
'yacht_booking_delete_from_gcal',
array( $booking_id )
);
} elseif ( 'confirmed' === $new_status ) {
if ( $event_id ) {
// Update existing event
wp_schedule_single_event(
time(),
'yacht_booking_update_in_gcal',
array( $booking_id )
);
} else {
// Create new event
wp_schedule_single_event(
time(),
'yacht_booking_sync_to_gcal',
array( $booking_id )
);
}
}
}
/**
* Handle booking deleted
*
* @param int $post_id Post ID.
*/
public function on_booking_deleted( $post_id ) {
// Check if it's a booking
if ( get_post_type( $post_id ) !== 'yacht_booking' ) {
return;
}
// Check if connected
if ( ! OAuth_Handler::is_connected() ) {
return;
}
$event_id = get_post_meta( $post_id, '_gcal_event_id', true );
if ( $event_id ) {
// Delete event from Google Calendar (sync now, before post is deleted)
GCal_Service::delete_event( $post_id );
}
}
/**
* Register cron actions
*/
public static function register_cron_actions() {
// Sync to Google Calendar
add_action( 'yacht_booking_sync_to_gcal', array( __CLASS__, 'sync_booking_to_gcal' ) );
// Update in Google Calendar
add_action( 'yacht_booking_update_in_gcal', array( __CLASS__, 'update_booking_in_gcal' ) );
// Delete from Google Calendar
add_action( 'yacht_booking_delete_from_gcal', array( __CLASS__, 'delete_booking_from_gcal' ) );
// Pull from Google Calendar (hourly)
add_action( 'yacht_booking_pull_from_gcal', array( __CLASS__, 'pull_from_gcal' ) );
}
/**
* Sync booking to Google Calendar (cron action)
*
* @param int $booking_id Booking post ID.
*/
public static function sync_booking_to_gcal( $booking_id ) {
$event_id = GCal_Service::create_event( $booking_id );
if ( $event_id ) {
self::log( sprintf( 'Booking #%d synced to Google Calendar (Event ID: %s)', $booking_id, $event_id ) );
} else {
self::log( sprintf( 'Failed to sync booking #%d to Google Calendar', $booking_id ), 'error' );
}
}
/**
* Update booking in Google Calendar (cron action)
*
* @param int $booking_id Booking post ID.
*/
public static function update_booking_in_gcal( $booking_id ) {
$result = GCal_Service::update_event( $booking_id );
if ( $result ) {
self::log( sprintf( 'Booking #%d updated in Google Calendar', $booking_id ) );
} else {
self::log( sprintf( 'Failed to update booking #%d in Google Calendar', $booking_id ), 'error' );
}
}
/**
* Delete booking from Google Calendar (cron action)
*
* @param int $booking_id Booking post ID.
*/
public static function delete_booking_from_gcal( $booking_id ) {
$result = GCal_Service::delete_event( $booking_id );
if ( $result ) {
self::log( sprintf( 'Booking #%d deleted from Google Calendar', $booking_id ) );
} else {
self::log( sprintf( 'Failed to delete booking #%d from Google Calendar', $booking_id ), 'error' );
}
}
/**
* Pull events from Google Calendar (cron action)
*/
public static function pull_from_gcal() {
// Get all yachts
$yachts = get_posts(
array(
'post_type' => 'yacht',
'posts_per_page' => -1,
)
);
foreach ( $yachts as $yacht ) {
$result = GCal_Service::sync_from_gcal( $yacht->ID );
if ( $result ) {
self::log( sprintf( 'Synced events from Google Calendar for yacht #%d', $yacht->ID ) );
} else {
self::log( sprintf( 'Failed to sync events from Google Calendar for yacht #%d', $yacht->ID ), 'error' );
}
}
}
/**
* Setup hourly cron job for pulling from Google Calendar
*/
public static function setup_cron() {
if ( ! wp_next_scheduled( 'yacht_booking_pull_from_gcal' ) ) {
wp_schedule_event( time(), 'hourly', 'yacht_booking_pull_from_gcal' );
}
}
/**
* Clear cron jobs
*/
public static function clear_cron() {
wp_clear_scheduled_hook( 'yacht_booking_pull_from_gcal' );
}
/**
* Log message
*
* @param string $message Message.
* @param string $type Log 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 - GCal Sync] [%s] %s', $prefix, $message ) );
}
}
/**
* AJAX handler for manual sync
*/
public function ajax_manual_sync() {
// Check nonce
check_ajax_referer( 'yacht_booking_manual_sync', 'nonce' );
// Check capabilities
if ( ! current_user_can( 'yacht_booking_manage_settings' ) ) {
wp_send_json_error( array( 'message' => __( 'Brak uprawnień', 'yacht-booking' ) ) );
}
// Check if connected
if ( ! OAuth_Handler::is_connected() ) {
wp_send_json_error( array( 'message' => __( 'Nie połączono z Google Calendar', 'yacht-booking' ) ) );
}
// Statistics
$yachts_synced = 0;
$bookings_pushed = 0;
$bookings_skipped = 0;
$errors = array();
// STEP 1: Push WordPress bookings to Google Calendar
$bookings = get_posts(
array(
'post_type' => 'yacht_booking',
'posts_per_page' => -1,
'post_status' => 'publish',
)
);
foreach ( $bookings as $booking ) {
$event_id = get_post_meta( $booking->ID, '_gcal_event_id', true );
$status = get_post_meta( $booking->ID, '_booking_status', true );
$source = get_post_meta( $booking->ID, '_booking_source', true );
// Never push imported Google placeholders back to Google.
if ( GCal_Service::EXTERNAL_BOOKING_SOURCE === $source ) {
$bookings_skipped++;
continue;
}
// Skip cancelled bookings
if ( 'cancelled' === $status ) {
$bookings_skipped++;
continue;
}
// Only push if not already in Google Calendar
if ( empty( $event_id ) ) {
$result = GCal_Service::create_event( $booking->ID );
if ( $result ) {
$bookings_pushed++;
self::log( sprintf( 'Manual sync: Pushed booking #%d to Google Calendar', $booking->ID ) );
} else {
self::log( sprintf( 'Manual sync: Failed to push booking #%d to Google Calendar', $booking->ID ), 'error' );
}
} else {
$bookings_skipped++;
}
}
// STEP 2: Pull external events from Google Calendar
$yachts = get_posts(
array(
'post_type' => 'yacht',
'posts_per_page' => -1,
)
);
foreach ( $yachts as $yacht ) {
$result = GCal_Service::sync_from_gcal( $yacht->ID );
if ( $result ) {
$yachts_synced++;
self::log( sprintf( 'Manual sync: Pulled events from Google Calendar for yacht #%d', $yacht->ID ) );
} else {
$errors[] = sprintf( __( 'Błąd synchronizacji dla jachtu: %s', 'yacht-booking' ), $yacht->post_title );
self::log( sprintf( 'Manual sync: Failed to pull events from Google Calendar for yacht #%d', $yacht->ID ), 'error' );
}
}
// Build success message
$messages = array();
if ( $bookings_pushed > 0 ) {
$messages[] = sprintf(
/* translators: %d: number of bookings pushed */
_n( 'Wysłano %d rezerwację do Google Calendar', 'Wysłano %d rezerwacji do Google Calendar', $bookings_pushed, 'yacht-booking' ),
$bookings_pushed
);
}
if ( $bookings_skipped > 0 ) {
$messages[] = sprintf(
/* translators: %d: number of bookings skipped */
_n( 'Pominięto %d rezerwację (już zsynchronizowana lub anulowana)', 'Pominięto %d rezerwacji (już zsynchronizowane lub anulowane)', $bookings_skipped, 'yacht-booking' ),
$bookings_skipped
);
}
if ( $yachts_synced > 0 ) {
$messages[] = sprintf(
/* translators: %d: number of yachts synced */
_n( 'Pobrano wydarzenia dla %d jachtu z Google Calendar', 'Pobrano wydarzenia dla %d jachtów z Google Calendar', $yachts_synced, 'yacht-booking' ),
$yachts_synced
);
}
if ( count( $errors ) > 0 ) {
wp_send_json_error(
array(
'message' => implode( '<br>', $errors ),
)
);
} else {
wp_send_json_success(
array(
'message' => implode( '<br>', $messages ),
)
);
}
}
}

View File

@@ -0,0 +1,221 @@
<?php
/**
* iCal Feed Generator (export)
*
* Generates .ics feed per yacht for subscription by Google Calendar or other apps.
*
* @package YachtBooking
*/
namespace YachtBooking\Integrations\ICal;
use YachtBooking\Booking;
use YachtBooking\Yacht;
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* ICal Feed class
*/
class ICal_Feed {
/**
* Register rewrite rules and query vars
*/
public static function register() {
add_action( 'init', array( __CLASS__, 'add_rewrite_rules' ) );
add_filter( 'query_vars', array( __CLASS__, 'add_query_vars' ) );
add_action( 'template_redirect', array( __CLASS__, 'handle_feed_request' ) );
}
/**
* Add rewrite rule for ical feed
*/
public static function add_rewrite_rules() {
add_rewrite_rule(
'^yacht-ical/([0-9]+)/([a-zA-Z0-9]+)\.ics$',
'index.php?yacht_ical_id=$matches[1]&yacht_ical_token=$matches[2]',
'top'
);
// Flush rewrite rules if our rule is not registered yet.
$rules = get_option( 'rewrite_rules' );
if ( is_array( $rules ) && ! isset( $rules['^yacht-ical/([0-9]+)/([a-zA-Z0-9]+)\.ics$'] ) ) {
flush_rewrite_rules( false );
}
}
/**
* Add query vars
*
* @param array $vars Query vars.
* @return array
*/
public static function add_query_vars( $vars ) {
$vars[] = 'yacht_ical_id';
$vars[] = 'yacht_ical_token';
return $vars;
}
/**
* Handle feed request
*/
public static function handle_feed_request() {
$yacht_id = (int) get_query_var( 'yacht_ical_id', 0 );
$token = get_query_var( 'yacht_ical_token', '' );
if ( ! $yacht_id || ! $token ) {
return;
}
$yacht = get_post( $yacht_id );
if ( ! $yacht || 'yacht' !== $yacht->post_type ) {
status_header( 404 );
exit;
}
$stored_token = self::get_feed_token( $yacht_id );
if ( ! $stored_token || ! hash_equals( $stored_token, $token ) ) {
status_header( 403 );
exit;
}
self::output_ics( $yacht );
}
/**
* Get or create feed token for a yacht
*
* @param int $yacht_id Yacht ID.
* @return string
*/
public static function get_feed_token( $yacht_id ) {
$token = get_post_meta( $yacht_id, '_yacht_ical_token', true );
if ( empty( $token ) ) {
$token = wp_generate_password( 24, false );
update_post_meta( $yacht_id, '_yacht_ical_token', $token );
}
return $token;
}
/**
* Regenerate feed token
*
* @param int $yacht_id Yacht ID.
* @return string
*/
public static function regenerate_token( $yacht_id ) {
$token = wp_generate_password( 24, false );
update_post_meta( $yacht_id, '_yacht_ical_token', $token );
return $token;
}
/**
* Get feed URL for a yacht
*
* @param int $yacht_id Yacht ID.
* @return string
*/
public static function get_feed_url( $yacht_id ) {
$token = self::get_feed_token( $yacht_id );
return home_url( sprintf( '/yacht-ical/%d/%s.ics', $yacht_id, $token ) );
}
/**
* Output .ics file
*
* @param \WP_Post $yacht Yacht post.
*/
private static function output_ics( $yacht ) {
$bookings = get_posts(
array(
'post_type' => 'yacht_booking',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_booking_yacht_id',
'value' => $yacht->ID,
),
array(
'key' => '_booking_status',
'value' => 'cancelled',
'compare' => '!=',
),
),
)
);
$site_name = get_bloginfo( 'name' );
$domain = wp_parse_url( home_url(), PHP_URL_HOST );
header( 'Content-Type: text/calendar; charset=utf-8' );
header( 'Content-Disposition: inline; filename="' . sanitize_file_name( $yacht->post_title ) . '.ics"' );
header( 'Cache-Control: no-cache, must-revalidate' );
$lines = array();
$lines[] = 'BEGIN:VCALENDAR';
$lines[] = 'VERSION:2.0';
$lines[] = 'PRODID:-//YachtBooking//NONSGML v1.0//PL';
$lines[] = 'CALSCALE:GREGORIAN';
$lines[] = 'METHOD:PUBLISH';
$lines[] = 'X-WR-CALNAME:' . self::escape_ical( $yacht->post_title . ' - ' . $site_name );
$lines[] = 'X-WR-TIMEZONE:Europe/Warsaw';
foreach ( $bookings as $booking ) {
$start = Booking::get_start_date( $booking->ID );
$end = Booking::get_end_date( $booking->ID );
$status = Booking::get_status( $booking->ID );
$name = Booking::get_customer_name( $booking->ID );
if ( ! $start || ! $end ) {
continue;
}
// iCal DTEND for all-day events is exclusive
$end_exclusive = gmdate( 'Ymd', strtotime( $end . ' +1 day' ) );
$created = get_the_date( 'Ymd\THis\Z', $booking );
$summary = sprintf( '%s - %s', $yacht->post_title, $name );
if ( 'pending' === $status ) {
$summary = '[' . __( 'Oczekująca', 'yacht-booking' ) . '] ' . $summary;
}
$lines[] = 'BEGIN:VEVENT';
$lines[] = 'UID:booking-' . $booking->ID . '@' . $domain;
$lines[] = 'DTSTART;VALUE=DATE:' . gmdate( 'Ymd', strtotime( $start ) );
$lines[] = 'DTEND;VALUE=DATE:' . $end_exclusive;
$lines[] = 'DTSTAMP:' . gmdate( 'Ymd\THis\Z' );
$lines[] = 'CREATED:' . $created;
$lines[] = 'SUMMARY:' . self::escape_ical( $summary );
$lines[] = 'STATUS:CONFIRMED';
$lines[] = 'TRANSP:OPAQUE';
$lines[] = 'END:VEVENT';
}
$lines[] = 'END:VCALENDAR';
echo implode( "\r\n", $lines ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- iCal format
exit;
}
/**
* Escape iCal text value
*
* @param string $text Text.
* @return string
*/
private static function escape_ical( $text ) {
$text = str_replace( '\\', '\\\\', $text );
$text = str_replace( ',', '\\,', $text );
$text = str_replace( ';', '\\;', $text );
$text = str_replace( "\n", '\\n', $text );
return $text;
}
}

View File

@@ -0,0 +1,392 @@
<?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\Yacht;
// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* ICal Import class
*/
class ICal_Import {
/**
* Booking source identifier for iCal imports.
*/
const IMPORT_SOURCE = 'ical_import';
/**
* Register cron actions
*/
public static function register() {
add_action( 'yacht_booking_ical_import', array( __CLASS__, 'run_import' ) );
}
/**
* Setup cron schedule
*/
public static function setup_cron() {
if ( ! wp_next_scheduled( 'yacht_booking_ical_import' ) ) {
wp_schedule_event( time(), 'hourly', 'yacht_booking_ical_import' );
}
}
/**
* Clear cron
*/
public static function clear_cron() {
wp_clear_scheduled_hook( 'yacht_booking_ical_import' );
}
/**
* Run import for all yachts that have an iCal URL configured.
*/
public static function run_import() {
$yachts = get_posts(
array(
'post_type' => 'yacht',
'posts_per_page' => -1,
'fields' => 'ids',
)
);
foreach ( $yachts as $yacht_id ) {
$url = self::get_import_url( $yacht_id );
if ( $url ) {
self::import_for_yacht( $yacht_id, $url );
}
}
}
/**
* Get iCal import URL for a yacht.
*
* @param int $yacht_id Yacht ID.
* @return string
*/
public static function get_import_url( $yacht_id ) {
return get_post_meta( $yacht_id, '_yacht_ical_import_url', true );
}
/**
* Set iCal import URL for a yacht.
*
* @param int $yacht_id Yacht ID.
* @param string $url iCal URL.
*/
public static function set_import_url( $yacht_id, $url ) {
update_post_meta( $yacht_id, '_yacht_ical_import_url', esc_url_raw( $url ) );
}
/**
* Import events from iCal URL for a specific yacht.
*
* @param int $yacht_id Yacht ID.
* @param string $url iCal URL.
* @return bool
*/
public static function import_for_yacht( $yacht_id, $url ) {
$response = wp_remote_get(
$url,
array(
'timeout' => 30,
'sslverify' => true,
)
);
if ( is_wp_error( $response ) ) {
self::log( sprintf( 'iCal fetch failed for yacht #%d: %s', $yacht_id, $response->get_error_message() ), 'error' );
return false;
}
$body = wp_remote_retrieve_body( $response );
if ( empty( $body ) ) {
self::log( sprintf( 'iCal empty response for yacht #%d', $yacht_id ), 'error' );
return false;
}
$events = self::parse_ics( $body );
$existing_map = self::get_existing_import_map( $yacht_id );
$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;
}
$seen_uids[] = $event['uid'];
$booking_id = isset( $existing_map[ $event['uid'] ] ) ? (int) $existing_map[ $event['uid'] ] : 0;
$booking_id = self::upsert_booking( $yacht_id, $event, $booking_id );
if ( ! $booking_id ) {
self::log( sprintf( 'Failed to upsert iCal event %s for yacht #%d', $event['uid'], $yacht_id ), 'error' );
}
}
// Remove stale imports (events deleted from external calendar)
foreach ( $existing_map as $uid => $booking_id ) {
if ( ! in_array( $uid, $seen_uids, true ) ) {
Availability::clear_booking_availability( $booking_id );
wp_delete_post( $booking_id, true );
}
}
update_post_meta( $yacht_id, '_yacht_ical_last_import', current_time( 'mysql' ) );
return true;
}
/**
* Parse .ics content into array of events.
*
* @param string $ics_content Raw .ics content.
* @return array
*/
private 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;
}
/**
* Get existing imported bookings map: uid => booking_id.
*
* @param int $yacht_id Yacht ID.
* @return array
*/
private static function get_existing_import_map( $yacht_id ) {
$bookings = get_posts(
array(
'post_type' => 'yacht_booking',
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'ids',
'meta_query' => array(
'relation' => 'AND',
array(
'key' => '_booking_source',
'value' => self::IMPORT_SOURCE,
),
array(
'key' => '_booking_yacht_id',
'value' => (int) $yacht_id,
),
),
)
);
$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;
}
/**
* Create or update imported booking placeholder.
*
* @param int $yacht_id Yacht ID.
* @param array $event Parsed event data.
* @param int $existing_id Existing booking ID (0 for new).
* @return int|false
*/
private static function upsert_booking( $yacht_id, $event, $existing_id = 0 ) {
$summary = ! empty( $event['summary'] ) ? sanitize_text_field( $event['summary'] ) : __( 'Blokada iCal', '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 */
__( 'Import iCal: %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', __( 'Import iCal', '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::IMPORT_SOURCE );
update_post_meta( $booking_id, '_ical_event_uid', $event['uid'] );
update_post_meta( $booking_id, '_booking_notes', $summary );
Availability::clear_booking_availability( $booking_id );
Availability::mark_as_booked( $yacht_id, $start_date, $end_date, $booking_id );
return (int) $booking_id;
}
/**
* Get last import time for yacht.
*
* @param int $yacht_id Yacht ID.
* @return string
*/
public static function get_last_import_time( $yacht_id ) {
return get_post_meta( $yacht_id, '_yacht_ical_last_import', true );
}
/**
* 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 ) );
}
}
}