From ae59450d25c8b638342bae256fb715069d61ea92 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 10 May 2026 07:43:27 +0000 Subject: [PATCH] feat: add ICS calendar feed sync MVP Admins can now configure external ICS calendar URLs and link them to specific items/locations. A cron job syncs the feed hourly and creates HOLIDAYS_ID blocking timeframes for each calendar event, preventing bookings during those periods. - src/Service/ICSImport.php: fetch, parse (UTC/TZID/floating/all-day), and sync VEVENT blocks into cb_timeframe posts; UID-based delta sync avoids duplicates and cleans up removed events - src/Wordpress/CustomPostType/ICSFeed.php: admin-only CPT for feed config (URL, item/location targets, lookahead window); includes "Sync now" row action and status display in metabox - src/Plugin.php: register ICSFeed CPT; add it to body-class filter - src/Service/Scheduler.php: add hourly ICS sync cron job - tests/php/Service/ICSImportTest.php: unit + integration tests for parser (UTC, floating, TZID, all-day, line folding) and sync logic (create, update, cleanup, idempotency) https://claude.ai/code/session_01WxALKfRznRXqZRu6LQ2a23 --- src/Plugin.php | 2 + src/Service/ICSImport.php | 383 +++++++++++++++++++++++ src/Service/Scheduler.php | 7 + src/Wordpress/CustomPostType/ICSFeed.php | 270 ++++++++++++++++ tests/php/Service/ICSImportTest.php | 242 ++++++++++++++ 5 files changed, 904 insertions(+) create mode 100644 src/Service/ICSImport.php create mode 100644 src/Wordpress/CustomPostType/ICSFeed.php create mode 100644 tests/php/Service/ICSImportTest.php diff --git a/src/Plugin.php b/src/Plugin.php index b7e143506..15f83d51f 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -184,6 +184,7 @@ private static function getCustomPostTypeClasses() { \CommonsBooking\Wordpress\CustomPostType\Map::class, \CommonsBooking\Wordpress\CustomPostType\Booking::class, \CommonsBooking\Wordpress\CustomPostType\Restriction::class, + \CommonsBooking\Wordpress\CustomPostType\ICSFeed::class, ]; } @@ -455,6 +456,7 @@ public static function filterAdminBodyClass( $classes ) { case Map::$postType: case Restriction::$postType: case Timeframe::$postType: + case \CommonsBooking\Wordpress\CustomPostType\ICSFeed::$postType: return $classes . ' ' . $cssClass; } diff --git a/src/Service/ICSImport.php b/src/Service/ICSImport.php new file mode 100644 index 000000000..fb77655e1 --- /dev/null +++ b/src/Service/ICSImport.php @@ -0,0 +1,383 @@ + 'cb_ics_feed', + 'post_status' => 'publish', + 'numberposts' => -1, + ] ); + + foreach ( $feeds as $feed ) { + self::syncFeed( $feed->ID ); + } + } + + /** + * Syncs a single ICS feed post. + */ + public static function syncFeed( int $feedPostId ): void { + $url = get_post_meta( $feedPostId, '_cb_ics_feed_url', true ); + if ( empty( $url ) ) { + update_post_meta( $feedPostId, '_cb_ics_feed_last_error', __( 'No ICS URL configured.', 'commonsbooking' ) ); + return; + } + + $icsContent = self::fetchICS( $url ); + if ( $icsContent === false ) { + // error already stored inside fetchICS + return; + } + + $events = self::parseVEvents( $icsContent ); + + $itemIds = (array) get_post_meta( $feedPostId, '_cb_ics_feed_item_ids', true ); + $locationIds = (array) get_post_meta( $feedPostId, '_cb_ics_feed_location_ids', true ); + $lookahead = (int) get_post_meta( $feedPostId, '_cb_ics_feed_lookahead_days', true ); + if ( $lookahead <= 0 ) { + $lookahead = 180; + } + + $itemIds = array_filter( array_map( 'intval', $itemIds ) ); + $locationIds = array_filter( array_map( 'intval', $locationIds ) ); + + if ( empty( $itemIds ) || empty( $locationIds ) ) { + update_post_meta( $feedPostId, '_cb_ics_feed_last_error', __( 'No items or locations configured.', 'commonsbooking' ) ); + return; + } + + $horizon = time() + ( $lookahead * DAY_IN_SECONDS ); + $now = strtotime( 'today midnight' ); + + // Collect UIDs from the parsed feed (within the lookahead window) + $currentUids = []; + foreach ( $events as $event ) { + if ( $event['end'] < $now || $event['start'] > $horizon ) { + continue; + } + $currentUids[] = $event['uid']; + } + + // Load existing synced timeframes for this feed + $existingPosts = get_posts( [ + 'post_type' => TimeframeCPT::$postType, + 'post_status' => 'publish', + 'numberposts' => -1, + 'fields' => 'ids', + 'meta_query' => [ [ 'key' => self::SOURCE_FEED_META, 'value' => $feedPostId ] ], + ] ); + + $existingByUid = []; + foreach ( $existingPosts as $postId ) { + $uid = get_post_meta( $postId, self::SOURCE_UID_META, true ); + if ( $uid ) { + $existingByUid[ $uid ][] = $postId; + } + } + + // Delete timeframes for events that are no longer in the feed + self::cleanupRemovedEvents( $existingByUid, $currentUids ); + + // Create or update timeframes for each event × each item+location pair + foreach ( $events as $event ) { + if ( $event['end'] < $now || $event['start'] > $horizon ) { + continue; + } + + foreach ( $itemIds as $itemId ) { + foreach ( $locationIds as $locationId ) { + $pairUid = $event['uid'] . ':' . $itemId . ':' . $locationId; + + if ( isset( $existingByUid[ $pairUid ] ) ) { + // Update the first match (there should only ever be one) + self::updateBlockingTimeframe( $existingByUid[ $pairUid ][0], $event ); + } else { + self::createBlockingTimeframe( $feedPostId, $event, $itemId, $locationId ); + } + } + } + } + + update_post_meta( $feedPostId, '_cb_ics_feed_last_sync', time() ); + update_post_meta( $feedPostId, '_cb_ics_feed_last_error', '' ); + } + + /** + * Fetches the raw ICS content from the given URL. + * Returns false and stores an error on failure. + */ + private static function fetchICS( string $url ): string|false { + $response = wp_safe_remote_get( $url, [ + 'timeout' => 15, + 'user-agent' => 'CommonsBooking ICS Sync/' . COMMONSBOOKING_VERSION, + ] ); + + if ( is_wp_error( $response ) ) { + return false; + } + + $code = wp_remote_retrieve_response_code( $response ); + if ( $code !== 200 ) { + return false; + } + + return wp_remote_retrieve_body( $response ); + } + + /** + * Parses VEVENT blocks from raw ICS content. + * Returns an array of ['uid', 'start', 'end', 'summary'] arrays. + * Timestamps are Unix timestamps in the WP site's local timezone. + */ + public static function parseVEvents( string $icsContent ): array { + // Unfold long lines (RFC 5545 line folding: CRLF + whitespace) + $icsContent = preg_replace( '/\r\n[ \t]/', '', $icsContent ); + $icsContent = preg_replace( '/\n[ \t]/', '', $icsContent ); + + $events = []; + + // Split on VEVENT blocks + if ( ! preg_match_all( '/BEGIN:VEVENT(.+?)END:VEVENT/s', $icsContent, $matches ) ) { + return $events; + } + + foreach ( $matches[1] as $block ) { + $uid = self::extractField( $block, 'UID' ); + $summary = self::extractField( $block, 'SUMMARY' ) ?: ''; + $summary = self::unescapeICSText( $summary ); + + // DTSTART and DTEND may carry TZID or VALUE=DATE parameters + $dtStartRaw = self::extractFieldWithParams( $block, 'DTSTART' ); + $dtEndRaw = self::extractFieldWithParams( $block, 'DTEND' ); + + if ( ! $dtStartRaw || ! $dtEndRaw || ! $uid ) { + continue; + } + + try { + $start = self::parseDatetime( $dtStartRaw['value'], $dtStartRaw['tzid'], $dtStartRaw['allDay'] ); + $end = self::parseDatetime( $dtEndRaw['value'], $dtEndRaw['tzid'], $dtEndRaw['allDay'] ); + } catch ( \Exception $e ) { + continue; + } + + // For all-day events, DTEND in ICS is exclusive (the day after) — subtract one second + if ( $dtEndRaw['allDay'] ) { + $end = $end->modify( '-1 second' ); + } + + $events[] = [ + 'uid' => $uid, + 'start' => $start->getTimestamp(), + 'end' => $end->getTimestamp(), + 'summary' => $summary, + ]; + } + + return $events; + } + + /** + * Extracts a simple field value from a VEVENT block. + */ + private static function extractField( string $block, string $fieldName ): ?string { + if ( preg_match( '/^' . preg_quote( $fieldName, '/' ) . '[;:][^\r\n]*/m', $block, $m ) ) { + // Strip the field name + parameters up to the colon + $line = $m[0]; + $pos = strpos( $line, ':' ); + if ( $pos !== false ) { + return trim( substr( $line, $pos + 1 ) ); + } + } + return null; + } + + /** + * Extracts a date/time field along with TZID and VALUE=DATE parameters. + * Returns ['value' => string, 'tzid' => string|null, 'allDay' => bool]. + */ + private static function extractFieldWithParams( string $block, string $fieldName ): ?array { + if ( ! preg_match( '/^' . preg_quote( $fieldName, '/' ) . '([^:]*):([^\r\n]*)/m', $block, $m ) ) { + return null; + } + + $params = $m[1]; // e.g. ";TZID=Europe/Berlin" or ";VALUE=DATE" + $value = trim( $m[2] ); + $tzid = null; + $allDay = false; + + if ( preg_match( '/TZID=([^;]+)/', $params, $tzMatch ) ) { + $tzid = trim( $tzMatch[1] ); + } + if ( str_contains( $params, 'VALUE=DATE' ) ) { + $allDay = true; + } + // Date-only values (8 chars, no T) are also all-day + if ( strlen( $value ) === 8 && ! str_contains( $value, 'T' ) ) { + $allDay = true; + } + + return [ 'value' => $value, 'tzid' => $tzid, 'allDay' => $allDay ]; + } + + /** + * Parses an ICS datetime string into a DateTimeImmutable in the WP site timezone. + * + * Handles: + * - UTC: 20240601T120000Z + * - Named TZ: 20240601T120000 + TZID=Europe/Berlin + * - Floating: 20240601T120000 (treated as site-local) + * - All-day: 20240601 + */ + public static function parseDatetime( string $value, ?string $tzid, bool $allDay = false ): \DateTimeImmutable { + $siteTz = wp_timezone(); + + if ( $allDay ) { + // YYYYMMDD — interpret as midnight in site timezone + $dt = \DateTimeImmutable::createFromFormat( 'Ymd', $value, $siteTz ); + if ( ! $dt ) { + throw new \InvalidArgumentException( "Cannot parse all-day date: $value" ); + } + return $dt->setTime( 0, 0, 0 ); + } + + // Strip trailing Z to check for UTC marker + $isUtc = str_ends_with( $value, 'Z' ); + $value = rtrim( $value, 'Z' ); + + if ( $isUtc ) { + $dt = \DateTimeImmutable::createFromFormat( 'Ymd\THis', $value, new \DateTimeZone( 'UTC' ) ); + if ( ! $dt ) { + throw new \InvalidArgumentException( "Cannot parse UTC datetime: $value" ); + } + return $dt->setTimezone( $siteTz ); + } + + if ( $tzid ) { + try { + $tz = new \DateTimeZone( $tzid ); + } catch ( \Exception $e ) { + $tz = $siteTz; + } + $dt = \DateTimeImmutable::createFromFormat( 'Ymd\THis', $value, $tz ); + if ( ! $dt ) { + throw new \InvalidArgumentException( "Cannot parse datetime with tzid=$tzid: $value" ); + } + return $dt->setTimezone( $siteTz ); + } + + // Floating time — treat as site local + $dt = \DateTimeImmutable::createFromFormat( 'Ymd\THis', $value, $siteTz ); + if ( ! $dt ) { + throw new \InvalidArgumentException( "Cannot parse floating datetime: $value" ); + } + return $dt; + } + + /** + * Creates a blocking HOLIDAYS_ID timeframe for the given event/item/location. + */ + private static function createBlockingTimeframe( int $feedPostId, array $event, int $itemId, int $locationId ): int { + $title = sanitize_text_field( $event['summary'] ) ?: __( 'ICS Block', 'commonsbooking' ); + + $postId = wp_insert_post( [ + 'post_title' => $title, + 'post_type' => TimeframeCPT::$postType, + 'post_status' => 'publish', + 'post_author' => self::getAdminUserId(), + ] ); + + if ( is_wp_error( $postId ) || ! $postId ) { + return 0; + } + + // Use start-of-day for start, end-of-day for end (matching full-day timeframe convention) + $startDay = strtotime( 'midnight', $event['start'] ); + $endDay = strtotime( 'midnight', $event['end'] ) + 86399; // +23h 59m 59s matches sanitizeRepetitionEndDate() + + update_post_meta( $postId, 'type', TimeframeCPT::HOLIDAYS_ID ); + update_post_meta( $postId, TimeframeModel::REPETITION_START, $startDay ); + update_post_meta( $postId, TimeframeModel::REPETITION_END, $endDay ); + update_post_meta( $postId, TimeframeModel::META_REPETITION, 'norep' ); + update_post_meta( $postId, 'full-day', 'on' ); + update_post_meta( $postId, 'start-time', '00:00' ); + update_post_meta( $postId, 'end-time', '23:59' ); + update_post_meta( $postId, 'grid', 0 ); + update_post_meta( $postId, TimeframeModel::META_ITEM_ID, $itemId ); + update_post_meta( $postId, TimeframeModel::META_LOCATION_ID, $locationId ); + update_post_meta( $postId, TimeframeModel::META_ITEM_SELECTION_TYPE, TimeframeModel::SELECTION_MANUAL_ID ); + update_post_meta( $postId, TimeframeModel::META_LOCATION_SELECTION_TYPE, TimeframeModel::SELECTION_MANUAL_ID ); + + // ICS tracking — UID includes item+location so each pair is independently tracked + update_post_meta( $postId, self::SOURCE_FEED_META, $feedPostId ); + update_post_meta( $postId, self::SOURCE_UID_META, $event['uid'] . ':' . $itemId . ':' . $locationId ); + + return $postId; + } + + /** + * Updates start/end dates of an existing synced timeframe. + */ + private static function updateBlockingTimeframe( int $timeframeId, array $event ): void { + $startDay = strtotime( 'midnight', $event['start'] ); + $endDay = strtotime( 'midnight', $event['end'] ) + 86399; + + update_post_meta( $timeframeId, TimeframeModel::REPETITION_START, $startDay ); + update_post_meta( $timeframeId, TimeframeModel::REPETITION_END, $endDay ); + + $title = sanitize_text_field( $event['summary'] ); + if ( $title ) { + wp_update_post( [ 'ID' => $timeframeId, 'post_title' => $title ] ); + } + } + + /** + * Deletes synced timeframes whose UIDs are no longer present in the feed. + * + * @param array $existingByUid Map of uid => [post_id, ...] + * @param array $currentUids UIDs still present in the feed (base UIDs without item/location suffix) + */ + private static function cleanupRemovedEvents( array $existingByUid, array $currentUids ): void { + // currentUids are base UIDs; existingByUid keys are pair UIDs (uid:itemId:locationId) + foreach ( $existingByUid as $pairUid => $postIds ) { + // Extract base UID by stripping :itemId:locationId suffix + $baseUid = preg_replace( '/:\d+:\d+$/', '', $pairUid ); + if ( ! in_array( $baseUid, $currentUids, true ) ) { + foreach ( $postIds as $postId ) { + wp_delete_post( $postId, true ); + } + } + } + } + + /** + * Unescapes ICS text field escape sequences. + */ + private static function unescapeICSText( string $text ): string { + return str_replace( [ '\\,', '\\;', '\\n', '\\N', '\\\\' ], [ ',', ';', "\n", "\n", '\\' ], $text ); + } + + /** + * Returns the ID of the first administrator user, used as post_author for generated timeframes. + */ + public static function getAdminUserId(): int { + $admins = get_users( [ 'role' => 'administrator', 'number' => 1, 'fields' => 'ID' ] ); + return ! empty( $admins ) ? (int) $admins[0] : 1; + } +} diff --git a/src/Service/Scheduler.php b/src/Service/Scheduler.php index 8f7fc65f6..502d69100 100644 --- a/src/Service/Scheduler.php +++ b/src/Service/Scheduler.php @@ -202,6 +202,13 @@ function () use ( $exportPath ) { 'update_option_commonsbooking_options_export' ); + // Init ICS feed sync job + new Scheduler( + 'ics_sync', + [ \CommonsBooking\Service\ICSImport::class, 'syncAllFeeds' ], + 'hourly' + ); + // Init cache warmup job $cacheWarmupSetting = Settings::getOption( COMMONSBOOKING_PLUGIN_SLUG . '_options_advanced-options', 'warmup_cron' ); if ( $cacheWarmupSetting ) { diff --git a/src/Wordpress/CustomPostType/ICSFeed.php b/src/Wordpress/CustomPostType/ICSFeed.php new file mode 100644 index 000000000..ecd186613 --- /dev/null +++ b/src/Wordpress/CustomPostType/ICSFeed.php @@ -0,0 +1,270 @@ +listColumns = [ + '_cb_ics_feed_url' => esc_html__( 'ICS URL', 'commonsbooking' ), + '_cb_ics_feed_last_sync' => esc_html__( 'Last Sync', 'commonsbooking' ), + '_cb_ics_feed_last_error'=> esc_html__( 'Status', 'commonsbooking' ), + ]; + $this->menuPosition = 7; + $this->removeListDateColumn(); + } + + /** + * Initiates needed hooks. + */ + public function initHooks() { + add_action( 'cmb2_admin_init', [ $this, 'registerMetabox' ] ); + add_action( 'save_post', [ $this, 'savePost' ], 11, 2 ); + add_action( 'admin_action_cb_ics_sync_now', [ self::class, 'handleSyncNow' ] ); + add_action( 'admin_notices', [ self::class, 'showSyncNotice' ] ); + add_filter( 'post_row_actions', [ self::class, 'addSyncNowRowAction' ], 10, 2 ); + } + + /** + * @inheritDoc + */ + public function getArgs(): array { + $labels = [ + 'name' => esc_html__( 'ICS Calendar Feeds', 'commonsbooking' ), + 'singular_name' => esc_html__( 'ICS Calendar Feed', 'commonsbooking' ), + 'add_new' => esc_html__( 'Add new', 'commonsbooking' ), + 'add_new_item' => esc_html__( 'Add new ICS Feed', 'commonsbooking' ), + 'edit_item' => esc_html__( 'Edit ICS Feed', 'commonsbooking' ), + 'new_item' => esc_html__( 'Add new ICS Feed', 'commonsbooking' ), + 'view_item' => esc_html__( 'View ICS Feed', 'commonsbooking' ), + 'search_items' => esc_html__( 'Search ICS Feeds', 'commonsbooking' ), + 'not_found' => esc_html__( 'No ICS Feeds found', 'commonsbooking' ), + 'not_found_in_trash' => esc_html__( 'No ICS Feeds found in trash', 'commonsbooking' ), + 'all_items' => esc_html__( 'All ICS Feeds', 'commonsbooking' ), + 'menu_name' => esc_html__( 'ICS Calendar Sync', 'commonsbooking' ), + ]; + + return [ + 'labels' => $labels, + 'public' => false, + 'show_ui' => true, + 'show_in_menu' => false, + 'menu_position' => $this->menuPosition, + 'show_in_admin_bar' => false, + 'show_in_nav_menus' => false, + 'capability_type' => [ self::$postType, self::$postType . 's' ], + 'map_meta_cap' => true, + 'publicly_queryable' => false, + 'exclude_from_search' => true, + 'supports' => [ 'title', 'author' ], + 'has_archive' => false, + 'can_export' => false, + 'show_in_rest' => false, + ]; + } + + /** + * Registers the CMB2 metabox with all configuration fields. + */ + public function registerMetabox() { + $cmb = new_cmb2_box( [ + 'id' => static::getPostType() . '-custom-fields', + 'title' => esc_html__( 'ICS Feed Configuration', 'commonsbooking' ), + 'object_types' => [ static::getPostType() ], + ] ); + + foreach ( $this->getCustomFields() as $field ) { + $cmb->add_field( $field ); + } + } + + /** + * Returns the CMB2 field definitions for this CPT. + */ + protected function getCustomFields(): array { + $fields = [ + [ + 'name' => esc_html__( 'ICS Calendar URL', 'commonsbooking' ), + 'desc' => esc_html__( 'Public URL of the ICS/iCalendar feed (must be accessible without login).', 'commonsbooking' ), + 'id' => '_cb_ics_feed_url', + 'type' => 'text_url', + ], + [ + 'name' => esc_html__( 'Items to block', 'commonsbooking' ), + 'desc' => esc_html__( 'Items that become unavailable during ICS events.', 'commonsbooking' ), + 'id' => '_cb_ics_feed_item_ids', + 'type' => 'multicheck', + 'options' => self::sanitizeOptions( \CommonsBooking\Repository\Item::getByCurrentUser() ), + ], + [ + 'name' => esc_html__( 'Locations to block', 'commonsbooking' ), + 'desc' => esc_html__( 'Locations where the selected items are blocked during ICS events.', 'commonsbooking' ), + 'id' => '_cb_ics_feed_location_ids', + 'type' => 'multicheck', + 'options' => self::sanitizeOptions( \CommonsBooking\Repository\Location::getByCurrentUser() ), + ], + [ + 'name' => esc_html__( 'Import events this many days ahead', 'commonsbooking' ), + 'desc' => esc_html__( 'Only events within this window are imported. Default: 180 days.', 'commonsbooking' ), + 'id' => '_cb_ics_feed_lookahead_days', + 'type' => 'text_small', + 'default' => '180', + 'attributes' => [ 'type' => 'number', 'min' => '1' ], + ], + [ + 'name' => esc_html__( 'Sync status', 'commonsbooking' ), + 'id' => '_cb_ics_feed_status_display', + 'type' => 'title', + 'render_row_cb' => [ self::class, 'renderSyncStatus' ], + ], + ]; + + return $fields; + } + + /** + * Renders the sync status row in the metabox (read-only). + */ + public static function renderSyncStatus( array $fieldArgs, \CMB2_Field $field ): void { + $postId = $field->object_id; + $lastSync = get_post_meta( $postId, '_cb_ics_feed_last_sync', true ); + $lastError = get_post_meta( $postId, '_cb_ics_feed_last_error', true ); + + echo '
'; + echo '
'; + echo '
'; + + if ( $lastSync ) { + printf( + '

' . esc_html__( 'Last successful sync: %s', 'commonsbooking' ) . '

', + esc_html( date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $lastSync ) ) + ); + } else { + echo '

' . esc_html__( 'Not yet synced.', 'commonsbooking' ) . '

'; + } + + if ( ! empty( $lastError ) ) { + echo '

' . esc_html( $lastError ) . '

'; + } + + if ( $postId && get_post_status( $postId ) === 'publish' ) { + $syncUrl = wp_nonce_url( + admin_url( 'admin.php?action=cb_ics_sync_now&post_id=' . $postId ), + 'cb_ics_sync_now_' . $postId + ); + echo '' . esc_html__( 'Sync now', 'commonsbooking' ) . ''; + } + + echo '
'; + } + + /** + * Adds a "Sync now" action link in the list view row. + */ + public static function addSyncNowRowAction( array $actions, \WP_Post $post ): array { + if ( $post->post_type !== self::$postType || $post->post_status !== 'publish' ) { + return $actions; + } + + $syncUrl = wp_nonce_url( + admin_url( 'admin.php?action=cb_ics_sync_now&post_id=' . $post->ID ), + 'cb_ics_sync_now_' . $post->ID + ); + $actions['cb_sync_now'] = '' . esc_html__( 'Sync now', 'commonsbooking' ) . ''; + + return $actions; + } + + /** + * Handles the "Sync now" admin action. + */ + public static function handleSyncNow(): void { + $postId = isset( $_GET['post_id'] ) ? (int) $_GET['post_id'] : 0; + if ( ! $postId || ! check_admin_referer( 'cb_ics_sync_now_' . $postId ) ) { + wp_die( esc_html__( 'Invalid request.', 'commonsbooking' ) ); + } + + if ( ! current_user_can( 'manage_' . COMMONSBOOKING_PLUGIN_SLUG ) ) { + wp_die( esc_html__( 'Insufficient permissions.', 'commonsbooking' ) ); + } + + ICSImport::syncFeed( $postId ); + + wp_safe_redirect( + add_query_arg( + [ 'post_type' => self::$postType, 'cb_ics_synced' => 1 ], + admin_url( 'edit.php' ) + ) + ); + exit; + } + + /** + * Displays an admin notice after a manual sync. + */ + public static function showSyncNotice(): void { + if ( + isset( $_GET['cb_ics_synced'] ) && + isset( $_GET['post_type'] ) && + sanitize_key( $_GET['post_type'] ) === self::$postType + ) { + echo '

'; + echo esc_html__( 'ICS feed synced successfully.', 'commonsbooking' ); + echo '

'; + } + } + + /** + * Modifies data in custom list columns. + */ + public function setCustomColumnsData( $column, $post_id ) { + switch ( $column ) { + case '_cb_ics_feed_url': + $url = get_post_meta( $post_id, '_cb_ics_feed_url', true ); + echo $url ? '' . esc_html( $url ) . '' : '—'; + break; + + case '_cb_ics_feed_last_sync': + $ts = get_post_meta( $post_id, '_cb_ics_feed_last_sync', true ); + echo $ts ? esc_html( date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), $ts ) ) : esc_html__( 'Never', 'commonsbooking' ); + break; + + case '_cb_ics_feed_last_error': + $err = get_post_meta( $post_id, '_cb_ics_feed_last_error', true ); + if ( empty( $err ) ) { + $lastSync = get_post_meta( $post_id, '_cb_ics_feed_last_sync', true ); + echo $lastSync ? '✓ ' . esc_html__( 'OK', 'commonsbooking' ) . '' : '—'; + } else { + echo '' . esc_html( $err ) . ''; + } + break; + } + } + + /** + * Handles saving the post — currently no extra logic needed beyond CMB2. + */ + public function savePost( $post_id, $post ) { + if ( $post->post_type !== self::$postType ) { + return; + } + if ( $this->hasRunBefore( __METHOD__ ) ) { + return; + } + } +} diff --git a/tests/php/Service/ICSImportTest.php b/tests/php/Service/ICSImportTest.php new file mode 100644 index 000000000..ce89d2369 --- /dev/null +++ b/tests/php/Service/ICSImportTest.php @@ -0,0 +1,242 @@ +assertCount( 2, $events ); + } + + public function testParseVEventsExtractsUidAndSummary() { + $events = ICSImport::parseVEvents( self::SAMPLE_ICS ); + $this->assertEquals( 'event-utc-001', $events[0]['uid'] ); + $this->assertEquals( 'UTC Event', $events[0]['summary'] ); + } + + public function testParseDatetimeUTC() { + $dt = ICSImport::parseDatetime( '20210701T080000Z', null, false ); + // The returned object must be in the site timezone + $this->assertEquals( 'UTC', $dt->getTimezone()->getName() ); + $this->assertEquals( strtotime( '2021-07-01 08:00:00 UTC' ), $dt->getTimestamp() ); + } + + public function testParseDatetimeFloating() { + $value = '20210701T120000'; + $dt = ICSImport::parseDatetime( $value, null, false ); + // Floating time is treated as site local — timestamp should match site timezone interpretation + $siteTz = wp_timezone(); + $expected = new \DateTimeImmutable( '2021-07-01 12:00:00', $siteTz ); + $this->assertEquals( $expected->getTimestamp(), $dt->getTimestamp() ); + } + + public function testParseDatetimeAllDay() { + $dt = ICSImport::parseDatetime( '20210701', null, true ); + // Should be midnight of that day in site timezone + $siteTz = wp_timezone(); + $expected = new \DateTimeImmutable( '2021-07-01 00:00:00', $siteTz ); + $this->assertEquals( $expected->getTimestamp(), $dt->getTimestamp() ); + } + + public function testParseDatetimeWithTZID() { + $dt = ICSImport::parseDatetime( '20210701T100000', 'Europe/Berlin', false ); + $expected = new \DateTimeImmutable( '2021-07-01 10:00:00', new \DateTimeZone( 'Europe/Berlin' ) ); + $this->assertEquals( $expected->getTimestamp(), $dt->getTimestamp() ); + } + + public function testParseVEventsUnfoldsLongLines() { + $folded = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:fold-test\r\nSUMMARY:This is a very long summ\r\n ary that was folded\r\nDTSTART:20210701T080000Z\r\nDTEND:20210701T100000Z\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + $events = ICSImport::parseVEvents( $folded ); + $this->assertCount( 1, $events ); + $this->assertEquals( 'This is a very long summary that was folded', $events[0]['summary'] ); + } + + public function testParseVEventsAllDayEndIsExclusive() { + // DTEND:20210703 means the block covers 20210701 and 20210702 only + $events = ICSImport::parseVEvents( self::SAMPLE_ICS ); + $allDay = $events[1]; + + $siteTz = wp_timezone(); + $startMidnight = ( new \DateTimeImmutable( '2021-07-02 00:00:00', $siteTz ) )->getTimestamp(); + // End should be one second before midnight of 20210703, i.e. end of 20210702 + $endOfDay = ( new \DateTimeImmutable( '2021-07-02 23:59:59', $siteTz ) )->getTimestamp(); + + $this->assertEquals( $startMidnight, $allDay['start'] ); + $this->assertEquals( $endOfDay, $allDay['end'] ); + } + + // ------------------------------------------------------------------------- + // Integration tests — creates real WP posts and asserts DB state + // ------------------------------------------------------------------------- + + public function testSyncFeedCreatesTimeframes() { + // Replace fetchICS via filter so no HTTP needed + add_filter( 'pre_http_request', [ $this, 'mockHttpResponse' ], 10, 3 ); + + ICSImport::syncFeed( $this->feedId ); + + remove_filter( 'pre_http_request', [ $this, 'mockHttpResponse' ] ); + + // Expect 2 events × 1 item × 1 location = 2 timeframes + $timeframes = get_posts( [ + 'post_type' => TimeframeCPT::$postType, + 'post_status' => 'publish', + 'numberposts' => -1, + 'meta_query' => [ [ 'key' => ICSImport::SOURCE_FEED_META, 'value' => $this->feedId ] ], + ] ); + + $this->assertCount( 2, $timeframes ); + + // Verify the type is HOLIDAYS_ID + foreach ( $timeframes as $tf ) { + $this->assertEquals( TimeframeCPT::HOLIDAYS_ID, get_post_meta( $tf->ID, 'type', true ) ); + } + + // Verify last sync meta is set + $this->assertNotEmpty( get_post_meta( $this->feedId, '_cb_ics_feed_last_sync', true ) ); + $this->assertEmpty( get_post_meta( $this->feedId, '_cb_ics_feed_last_error', true ) ); + } + + public function testSyncFeedCleansUpRemovedEvents() { + add_filter( 'pre_http_request', [ $this, 'mockHttpResponse' ], 10, 3 ); + ICSImport::syncFeed( $this->feedId ); + remove_filter( 'pre_http_request', [ $this, 'mockHttpResponse' ] ); + + // 2 timeframes created + $timeframes = get_posts( [ + 'post_type' => TimeframeCPT::$postType, + 'numberposts' => -1, + 'meta_query' => [ [ 'key' => ICSImport::SOURCE_FEED_META, 'value' => $this->feedId ] ], + ] ); + $this->assertCount( 2, $timeframes ); + + // Second sync with only 1 event in the ICS + add_filter( 'pre_http_request', [ $this, 'mockHttpResponseSingle' ], 10, 3 ); + ICSImport::syncFeed( $this->feedId ); + remove_filter( 'pre_http_request', [ $this, 'mockHttpResponseSingle' ] ); + + $remaining = get_posts( [ + 'post_type' => TimeframeCPT::$postType, + 'numberposts' => -1, + 'meta_query' => [ [ 'key' => ICSImport::SOURCE_FEED_META, 'value' => $this->feedId ] ], + ] ); + + // Only 1 timeframe should remain (the removed event's timeframe is deleted) + $this->assertCount( 1, $remaining ); + } + + public function testSyncFeedDoesNotDuplicateOnReSyncSameEvents() { + add_filter( 'pre_http_request', [ $this, 'mockHttpResponse' ], 10, 3 ); + ICSImport::syncFeed( $this->feedId ); + ICSImport::syncFeed( $this->feedId ); // sync twice + remove_filter( 'pre_http_request', [ $this, 'mockHttpResponse' ] ); + + $timeframes = get_posts( [ + 'post_type' => TimeframeCPT::$postType, + 'numberposts' => -1, + 'meta_query' => [ [ 'key' => ICSImport::SOURCE_FEED_META, 'value' => $this->feedId ] ], + ] ); + + // Still only 2, not 4 + $this->assertCount( 2, $timeframes ); + } + + public function testSyncFeedErrorOnMissingUrl() { + // Feed with no URL set + $emptyFeedId = wp_insert_post( [ + 'post_title' => 'Empty Feed', + 'post_type' => 'cb_ics_feed', + 'post_status' => 'publish', + ] ); + update_post_meta( $emptyFeedId, '_cb_ics_feed_item_ids', [ $this->itemId ] ); + update_post_meta( $emptyFeedId, '_cb_ics_feed_location_ids', [ $this->locationId ] ); + + ICSImport::syncFeed( $emptyFeedId ); + + $error = get_post_meta( $emptyFeedId, '_cb_ics_feed_last_error', true ); + $this->assertNotEmpty( $error ); + + wp_delete_post( $emptyFeedId, true ); + } + + // ------------------------------------------------------------------------- + // HTTP mock helpers + // ------------------------------------------------------------------------- + + public function mockHttpResponse( $preempt, $args, $url ) { + return [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => self::SAMPLE_ICS, + 'headers' => [], + 'cookies' => [], + ]; + } + + public function mockHttpResponseSingle( $preempt, $args, $url ) { + return [ + 'response' => [ 'code' => 200, 'message' => 'OK' ], + 'body' => self::SAMPLE_ICS_SINGLE, + 'headers' => [], + 'cookies' => [], + ]; + } + + // ------------------------------------------------------------------------- + // Setup / teardown + // ------------------------------------------------------------------------- + + protected function setUp(): void { + parent::setUp(); + + // Register the cb_ics_feed post type so wp_insert_post works in tests + if ( ! post_type_exists( 'cb_ics_feed' ) ) { + register_post_type( 'cb_ics_feed', [ 'public' => false, 'label' => 'ICS Feed' ] ); + } + + // Create a feed post targeting the test item + location + $this->feedId = wp_insert_post( [ + 'post_title' => 'Test ICS Feed', + 'post_type' => 'cb_ics_feed', + 'post_status' => 'publish', + ] ); + update_post_meta( $this->feedId, '_cb_ics_feed_url', 'https://example.com/test.ics' ); + update_post_meta( $this->feedId, '_cb_ics_feed_item_ids', [ $this->itemId ] ); + update_post_meta( $this->feedId, '_cb_ics_feed_location_ids', [ $this->locationId ] ); + update_post_meta( $this->feedId, '_cb_ics_feed_lookahead_days', 365 ); + } + + protected function tearDown(): void { + wp_delete_post( $this->feedId, true ); + + // Clean up any synced timeframes created during tests + $synced = get_posts( [ + 'post_type' => TimeframeCPT::$postType, + 'numberposts' => -1, + 'fields' => 'ids', + 'meta_query' => [ [ 'key' => ICSImport::SOURCE_FEED_META ] ], + ] ); + foreach ( $synced as $id ) { + wp_delete_post( $id, true ); + } + + parent::tearDown(); + } +}