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,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 ) );
}
}
}