'API key is required']); } $client = new UplistingClient($api_key); $result = $client->test_connection(); if ($result === true) { // It connected successfully, so now fetch and cache properties update_option('bizdex_uplisting_api_key', $api_key); $properties = $client->get_properties(); if (is_array($properties)) { set_transient('bizdex_uplisting_properties_cache', $properties, HOUR_IN_SECONDS); wp_send_json_success(['message' => 'Connection successful! ' . count($properties) . ' properties found.']); } else { wp_send_json_success(['message' => 'Connection successful, but failed to parse properties.']); } } else { wp_send_json_error(['message' => $result]); } } /** * Fetch Uplisting properties with transient caching. * * Transient: `bizdex_uplisting_properties_cache` (1 hour). Returns false when API * key is missing or the request fails. * * @param bool $force When true, bypass cache and refetch from API. * @return array>|false Normalized property list. */ public function get_properties($force = false) { $cached = get_transient('bizdex_uplisting_properties_cache'); if (!$force && $cached !== false) { return $cached; } $api_key = get_option('bizdex_uplisting_api_key', ''); if (empty($api_key)) { return false; } $client = new UplistingClient($api_key); $result = $client->get_properties(); if (is_array($result)) { set_transient('bizdex_uplisting_properties_cache', $result, HOUR_IN_SECONDS); return $result; } return false; } /** * Check if a property is fully available for given dates * * @param int|string $property_id * @param string $check_in (YYYY-MM-DD) * @param string $check_out (YYYY-MM-DD) * @return bool|\WP_Error True if available, WP_Error if not or API fails. */ public function check_property_availability($property_id, $check_in, $check_out) { if (empty($check_in) || empty($check_out)) { return new \WP_Error('invalid_dates', __('Check-in and check-out dates are required.', 'advanced-connector-for-uplisting')); } if (get_post_type($property_id) !== 'bizdexul_property') { return new \WP_Error('invalid_property', __('Invalid property.', 'advanced-connector-for-uplisting')); } $property = new PropertyDetails((int) $property_id); if (!$property->get_id()) { return new \WP_Error('invalid_property', __('Invalid property.', 'advanced-connector-for-uplisting')); } if (!$property->uses_uplisting_api_for_calendar()) { // Inquiry form (uplisting_booking_form): iCal-synced busy dates only. if (BookingDetails::stay_overlaps_busy_dates($check_in, $check_out, $property->get_busy_dates())) { return new \WP_Error('unavailable', __('Dates are no longer available.', 'advanced-connector-for-uplisting')); } return true; } // PMS integration (booking_form): live Uplisting API calendar check below. $api_key = get_option('bizdex_uplisting_api_key', ''); if (empty($api_key)) { return new \WP_Error('no_api_key', __('Uplisting API key is missing.', 'advanced-connector-for-uplisting')); } $source_id = (string) $property->get_property_source_id(); if ($source_id === '') { return new \WP_Error('missing_source_id', __('Property is not linked to Uplisting.', 'advanced-connector-for-uplisting')); } $last_night = $check_out; $calendar_end = BookingDetails::last_night_to_checkout($last_night); $client = new UplistingClient($api_key); $calendar = $client->get_calendar($source_id, $check_in, $calendar_end); if (is_wp_error($calendar)) { return $calendar; } $booking = new BookingDetails(); $calendar_totals = $booking->get_calculated_calendar_totals( BookingDetails::extract_calendar_days($calendar), $check_in, $last_night ); if (empty($calendar_totals['available'])) { return new \WP_Error('unavailable', __('Dates are no longer available.', 'advanced-connector-for-uplisting')); } return true; } /** * Verify property availability for the requested check-in and check-out dates. * * @param array $input Prepared request input. * @return array{success: bool, data: array, status?: int} */ public function verify_availability(array $input = []): array { $property_id = isset($input['property_id']) ? absint($input['property_id']) : 0; $check_in = isset($input['check_in']) ? sanitize_text_field((string) $input['check_in']) : ''; $check_out = isset($input['check_out']) ? sanitize_text_field((string) $input['check_out']) : ''; if ($property_id <= 0 || $check_in === '' || $check_out === '') { return RestEndpointHelper::build_error_result(['message' => __('Missing property or dates.', 'advanced-connector-for-uplisting')]); } if (strtotime($check_out) <= strtotime($check_in)) { return RestEndpointHelper::build_error_result(['message' => __('Invalid check-in or check-out date.', 'advanced-connector-for-uplisting')]); } if (get_post_type($property_id) !== 'bizdexul_property') { return RestEndpointHelper::build_error_result(['message' => __('Invalid property.', 'advanced-connector-for-uplisting')]); } $result = $this->check_property_availability($property_id, $check_in, $check_out); if (is_wp_error($result)) { return RestEndpointHelper::build_error_result(['message' => $result->get_error_message()]); } return RestEndpointHelper::build_success_result(['message' => __('Dates are available.', 'advanced-connector-for-uplisting')]); } /** * AJAX: import or update a single property from Uplisting into WordPress. * * Action: `bizdex_uplisting_import_property`. Creates/updates `property` CPT posts, * sideloads photos, maps amenities, and stores Uplisting meta fields. * * @return void */ public function ajax_import_property() { Request::require_ajax_security('bizdex_uplisting_import_property', 'nonce'); $property_id = Request::text('post', 'property_id'); $existing_post_id = Request::absint('post', 'post_id'); /** * Fires before an Uplisting property import/update AJAX run. * * @param string $property_id * @param int $existing_post_id */ do_action('bizdex_uplisting_before_import_property', $property_id, $existing_post_id); if ($property_id === '') { wp_send_json_error(['message' => 'Property ID is missing.']); } if ($existing_post_id > 0 && get_post_type($existing_post_id) !== 'bizdexul_property') { wp_send_json_error(['message' => 'Invalid property post ID.']); } $api_key = get_option('bizdex_uplisting_api_key', ''); if (empty($api_key)) { wp_send_json_error(['message' => 'API key is missing. Please connect first.']); } $client = new UplistingClient($api_key); $property_data = $client->get_property($property_id); if (!$property_data || is_wp_error($property_data)) { $msg = is_wp_error($property_data) ? $property_data->get_error_message() : 'Property data not found.'; wp_send_json_error(['message' => $msg]); } // Determine if we are updating an existing post if (!$existing_post_id) { // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Find existing property by source id before import. $existing = get_posts([ 'post_type' => 'bizdexul_property', 'post_status' => 'any', 'posts_per_page' => 1, 'meta_query' => [ 'relation' => 'OR', [ 'key' => bizdex_uplisting_meta_key('property_source_id'), 'value' => $property_id, ], [ 'key' => 'property_source_id', 'value' => $property_id, ], ], ]); // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_query if (!empty($existing)) { $existing_post_id = $existing[0]->ID; } } $is_update = (bool) $existing_post_id; $post_args = [ 'post_title' => sanitize_text_field($property_data['name'] ?? ''), 'post_content' => wp_kses_post($property_data['description'] ?? ''), 'post_status' => 'publish', 'post_type' => 'bizdexul_property', ]; if ($existing_post_id) { $post_args['ID'] = $existing_post_id; $post_id = wp_update_post($post_args, true); $message = 'Property updated successfully.'; } else { $post_id = wp_insert_post($post_args, true); $message = 'Property imported successfully.'; } if (is_wp_error($post_id)) { wp_send_json_error(['message' => 'Failed to save property: ' . $post_id->get_error_message()]); } // Save core meta update_post_meta($post_id, bizdex_uplisting_meta_key('property_source_id'), sanitize_text_field((string) $property_id)); if (!empty($property_data['address']) && is_array($property_data['address'])) { $this->save_property_address_meta($post_id, $property_data['address']); } if (!empty($property_data['guests'])) { update_post_meta($post_id, bizdex_uplisting_meta_key('guests'), absint($property_data['guests'])); } if (!empty($property_data['bedrooms'])) { update_post_meta($post_id, bizdex_uplisting_meta_key('bedrooms'), absint($property_data['bedrooms'])); } if (!empty($property_data['baths'])) { update_post_meta($post_id, bizdex_uplisting_meta_key('bathrooms'), absint($property_data['baths'])); } if (!empty($property_data['beds'])) { update_post_meta($post_id, bizdex_uplisting_meta_key('beds'), absint($property_data['beds'])); } if (!empty($property_data['check_in_time'])) { update_post_meta($post_id, bizdex_uplisting_meta_key('check_in_time'), sanitize_text_field((string) $property_data['check_in_time'])); } if (!empty($property_data['reservation_time'])) { update_post_meta($post_id, bizdex_uplisting_meta_key('reservation_time'), sanitize_text_field((string) $property_data['reservation_time'])); } if (!empty($property_data['amenities']) && is_array($property_data['amenities'])) { $this->assign_property_amenities($post_id, $property_data['amenities']); } // Save pricing and fees if (isset($property_data['default_daily_rate']) && $property_data['default_daily_rate'] !== '') { update_post_meta($post_id, bizdex_uplisting_meta_key('uplisting_base_price'), is_numeric($property_data['default_daily_rate']) ? (float) $property_data['default_daily_rate'] : 0.0); } if (isset($property_data['fees'])) { $existing_fees = bizdex_uplisting_get_post_meta($post_id, 'uplisting_fees', true); $fees = PropertyDetails::sanitize_charge_rows( PropertyDetails::merge_charge_rows_apply_flags( is_array($property_data['fees']) ? $property_data['fees'] : [], is_array($existing_fees) ? $existing_fees : [] ), 'fee' ); update_post_meta($post_id, bizdex_uplisting_meta_key('uplisting_fees'), $fees); } if (isset($property_data['taxes'])) { $existing_taxes = bizdex_uplisting_get_post_meta($post_id, 'uplisting_taxes', true); $taxes = PropertyDetails::sanitize_charge_rows( PropertyDetails::merge_charge_rows_apply_flags( is_array($property_data['taxes']) ? $property_data['taxes'] : [], is_array($existing_taxes) ? $existing_taxes : [] ), 'tax' ); update_post_meta($post_id, bizdex_uplisting_meta_key('uplisting_taxes'), $taxes); } if (isset($property_data['discounts'])) { $existing_discounts = bizdex_uplisting_get_post_meta($post_id, 'uplisting_discounts', true); $discounts = PropertyDetails::sanitize_charge_rows( PropertyDetails::merge_charge_rows_apply_flags( is_array($property_data['discounts']) ? $property_data['discounts'] : [], is_array($existing_discounts) ? $existing_discounts : [] ), 'discount' ); update_post_meta($post_id, bizdex_uplisting_meta_key('uplisting_discounts'), $discounts); } PropertyDetails::sync_imported_pricing_charges($post_id, is_array($property_data) ? $property_data : []); if (isset($property_data['security_deposit']) && is_array($property_data['security_deposit'])) { $deposit = $property_data['security_deposit']; update_post_meta($post_id, bizdex_uplisting_meta_key('uplisting_security_deposit'), [ 'enabled' => !empty($deposit['enabled']) ? 1 : 0, 'amount' => isset($deposit['amount']) && is_numeric($deposit['amount']) ? (float) $deposit['amount'] : 0.0, ]); } // Property Information update_post_meta($post_id, bizdex_uplisting_meta_key('property_name'), sanitize_text_field((string) ($property_data['name'] ?? ''))); if (!empty($property_data['license'])) { update_post_meta($post_id, bizdex_uplisting_meta_key('property_license'), sanitize_text_field((string) $property_data['license'])); } // House Rules & Policies if (!empty($property_data['house_rules'])) { $house_rules = $property_data['house_rules']; if (is_array($house_rules)) { $house_rules = array_values(array_filter(array_map( static fn($rule): string => sanitize_text_field((string) $rule), $house_rules ))); } else { $house_rules = [sanitize_textarea_field((string) $house_rules)]; } update_post_meta($post_id, bizdex_uplisting_meta_key('house_rules_and_policies'), $house_rules); } // Store remote CDN gallery URLs (listing photos stay on the Uplisting CDN). if (!empty($property_data['photos']) && is_array($property_data['photos'])) { $remote_photos = []; foreach ($property_data['photos'] as $index => $photo_url) { $safe_url = esc_url_raw((string) $photo_url); if ($safe_url === '') { continue; } $remote_photos[] = [ 'id' => 'import-' . absint($index), 'url' => $safe_url, 'order' => absint($index), ]; } if (!empty($remote_photos)) { update_post_meta($post_id, bizdex_uplisting_meta_key('property_gallery_remote'), wp_json_encode($remote_photos)); $this->ensure_featured_image_from_first_photo( (int) $post_id, (string) ($remote_photos[0]['url'] ?? ''), sanitize_text_field((string) ($property_data['name'] ?? get_the_title($post_id))) ); } } $booking_option = (string) bizdex_uplisting_get_post_meta($post_id, 'booking_option', true); if ($booking_option !== 'uplisting_booking_form') { $this->sync_property_listing_price_today((int) $post_id); } wp_send_json_success([ 'property_data' => $property_data, 'message' => $message, 'post_id' => $post_id, 'is_update' => $is_update, 'edit_link' => get_edit_post_link($post_id, 'raw') ?: admin_url('post.php?post=' . $post_id . '&action=edit'), ]); } /** * Persist Uplisting address attributes to property post meta. * * @param int $post_id Property post ID. * @param array $address Address block from {@see UplistingClient::process_properties_response()}. */ private function save_property_address_meta(int $post_id, array $address): void { $street = $address['street'] ?? $address['address_line_1'] ?? ''; $suite = $address['suite'] ?? ''; $city = $address['city'] ?? ''; $state = $address['state'] ?? $address['county'] ?? ''; $zip = $address['zip_code'] ?? $address['zip'] ?? $address['postal_code'] ?? ''; $country = $address['country'] ?? ''; update_post_meta($post_id, bizdex_uplisting_meta_key('street'), sanitize_text_field((string) $street)); update_post_meta($post_id, bizdex_uplisting_meta_key('suite'), sanitize_text_field((string) $suite)); update_post_meta($post_id, bizdex_uplisting_meta_key('city'), sanitize_text_field((string) $city)); update_post_meta($post_id, bizdex_uplisting_meta_key('state_territory'), sanitize_text_field((string) $state)); update_post_meta($post_id, bizdex_uplisting_meta_key('zip_code'), sanitize_text_field((string) $zip)); update_post_meta($post_id, bizdex_uplisting_meta_key('country'), sanitize_text_field((string) $country)); if (isset($address['latitude']) && $address['latitude'] !== '' && $address['latitude'] !== null && is_numeric($address['latitude'])) { update_post_meta($post_id, bizdex_uplisting_meta_key('latitude'), (string) (float) $address['latitude']); } if (isset($address['longitude']) && $address['longitude'] !== '' && $address['longitude'] !== null && is_numeric($address['longitude'])) { update_post_meta($post_id, bizdex_uplisting_meta_key('longitude'), (string) (float) $address['longitude']); } } /** * Assign amenities to a property via taxonomy terms and post meta. * * @param int $post_id Property post ID. * @param string[] $amenities Amenity names from Uplisting. */ private function assign_property_amenities(int $post_id, array $amenities): void { $amenities = array_values(array_filter(array_map('sanitize_text_field', $amenities))); if (empty($amenities)) { return; } update_post_meta($post_id, 'bizdexul_amenity', $amenities); $amenity_term_ids = []; foreach ($amenities as $amenity_name) { if ('' === $amenity_name) { continue; } $term = term_exists($amenity_name, 'bizdexul_amenity'); if (!$term) { $term = wp_insert_term($amenity_name, 'bizdexul_amenity'); } if (is_wp_error($term)) { continue; } $term_id = is_array($term) ? (int) ($term['term_id'] ?? 0) : (int) $term; if ($term_id > 0) { $amenity_term_ids[] = $term_id; } } if (!empty($amenity_term_ids)) { wp_set_object_terms($post_id, $amenity_term_ids, 'bizdexul_amenity', false); } } /** * Sideload the first photo and set it as featured image only when none exists. * * Uses {@see PropertyDetails::maybe_set_featured_image()} so re-import never * overwrites an existing featured image. */ private function ensure_featured_image_from_first_photo(int $post_id, string $image_url, string $description = ''): void { if ($post_id <= 0 || $image_url === '' || has_post_thumbnail($post_id)) { return; } $featured_id = $this->sideload_image($image_url, $post_id, $description !== '' ? $description : null); if (is_wp_error($featured_id) || absint($featured_id) <= 0) { return; } $featured_id = absint($featured_id); if (!PropertyDetails::maybe_set_featured_image($post_id, $featured_id)) { return; } } /** * Helper to sideload image from URL */ private function sideload_image($url, $post_id, $desc = null) { // Check if image already exists for this post // phpcs:disable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value -- Deduplicate sideloaded images by source URL. $existing = get_posts([ 'post_type' => 'attachment', 'posts_per_page' => 1, 'post_parent' => $post_id, 'meta_key' => bizdex_uplisting_meta_key('_uplisting_source_url'), 'meta_value' => $url, 'fields' => 'ids', ]); // phpcs:enable WordPress.DB.SlowDBQuery.slow_db_query_meta_key,WordPress.DB.SlowDBQuery.slow_db_query_meta_value if (!empty($existing)) { return $existing[0]; } require_once(ABSPATH . 'wp-admin/includes/media.php'); require_once(ABSPATH . 'wp-admin/includes/file.php'); require_once(ABSPATH . 'wp-admin/includes/image.php'); $tmp = download_url($url); if (is_wp_error($tmp)) { return $tmp; } $file_array = [ 'name' => basename((string) wp_parse_url($url, PHP_URL_PATH)), 'tmp_name' => $tmp ]; $id = media_handle_sideload($file_array, $post_id, $desc); if (is_wp_error($id)) { wp_delete_file($file_array['tmp_name']); return $id; } // Save original URL so we don't duplicate it on next sync update_post_meta($id, bizdex_uplisting_meta_key('_uplisting_source_url'), $url); return $id; } /** * Schedule daily WP-Cron event for calendar synchronization. * * Hook: `wp`. Registers `bizdex_uplisting_calendar_sync` if not already scheduled. * * @return void */ public function schedule_bizdex_uplisting_sync() { if (!wp_next_scheduled('bizdex_uplisting_calendar_sync')) { wp_schedule_event(time(), 'twicedaily', 'bizdex_uplisting_calendar_sync'); } } /** * Register custom WP-Cron intervals. * * @param array $schedules * @return array */ public function register_cron_schedules(array $schedules): array { $schedules['bizdex_uplisting_every_three_hours'] = [ 'interval' => 3 * HOUR_IN_SECONDS, 'display' => __('Every 3 Hours', 'advanced-connector-for-uplisting'), ]; return $schedules; } /** * Schedule listing price sync every 3 hours. */ public function schedule_listing_price_sync(): void { if (!wp_next_scheduled('bizdex_uplisting_listing_price_sync')) { wp_schedule_event(time(), 'bizdex_uplisting_every_three_hours', 'bizdex_uplisting_listing_price_sync'); } $today = wp_date('Y-m-d'); if (get_option('bizdex_uplisting_listing_prices_synced_date', '') === $today) { return; } if (get_transient('bizdex_uplisting_listing_price_sync_queued')) { return; } set_transient('bizdex_uplisting_listing_price_sync_queued', 1, 15 * MINUTE_IN_SECONDS); wp_schedule_single_event(time() + 30, 'bizdex_uplisting_listing_price_sync'); } /** * Cron: refresh today's listing price from the calendar API for all API properties. */ public function sync_uplisting_listing_prices(): void { if (!get_option('bizdex_uplisting_api_key', '')) { return; } $query = new \WP_Query([ 'post_type' => 'bizdexul_property', 'posts_per_page' => -1, 'post_status' => 'publish', 'fields' => 'ids', // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Select properties with Uplisting source ids for sync. 'meta_query' => [ 'relation' => 'AND', [ 'relation' => 'OR', [ 'key' => bizdex_uplisting_meta_key('property_source_id'), 'value' => '', 'compare' => '!=', ], [ 'key' => 'property_source_id', 'value' => '', 'compare' => '!=', ], ], [ 'relation' => 'OR', [ 'key' => bizdex_uplisting_meta_key('booking_option'), 'compare' => 'NOT EXISTS', ], [ 'key' => bizdex_uplisting_meta_key('booking_option'), 'value' => '', 'compare' => '=', ], [ 'key' => bizdex_uplisting_meta_key('booking_option'), 'value' => 'uplisting_booking_form', 'compare' => '!=', ], ], ], ]); foreach ($query->posts as $post_id) { $this->sync_property_listing_price_today((int) $post_id); usleep(100000); } update_option('bizdex_uplisting_listing_prices_synced_date', wp_date('Y-m-d'), false); } /** * Fetch today's calendar day and store the current nightly rate. */ public function sync_property_listing_price_today(int $post_id): bool { $booking_option = (string) bizdex_uplisting_get_post_meta($post_id, 'booking_option', true); if ($booking_option === 'uplisting_booking_form') { return false; } $property_source_id = (string) bizdex_uplisting_get_post_meta($post_id, 'property_source_id', true); $query_api = (string) get_option('bizdex_uplisting_api_key', ''); if ($property_source_id === '' || $query_api === '') { return false; } $today = wp_date('Y-m-d'); $end_date = wp_date('Y-m-d', strtotime('+1 day')); $client = new UplistingClient($query_api); $data = $client->get_calendar($property_source_id, $today, $end_date); if (is_wp_error($data)) { return false; } return $this->save_listing_price_from_calendar($post_id, $data); } /** * Cron callback: sync calendar/busy dates for all imported properties. * * Iterates property posts with `property_source_id` meta and calls * {@see sync_property_calendar()} on each. * * @return void */ public function sync_uplisting_calendar_data() { $query = new \WP_Query([ 'post_type' => 'bizdexul_property', 'posts_per_page' => -1, 'post_status' => 'publish', ]); if ($query->have_posts()) { while ($query->have_posts()) { $query->the_post(); $this->sync_property_calendar(get_the_ID()); usleep(100000); // 0.1s delay to avoid rate limiting } wp_reset_postdata(); } bizdex_uplisting_log('Uplisting sync completed at ' . current_time('mysql')); } /** * Sync a single property via the best available method (API or iCal). * * @param int $post_id * @return bool */ public function sync_property_calendar($post_id) { $booking_option = bizdex_uplisting_get_post_meta($post_id, 'booking_option', true); $property_source_id = bizdex_uplisting_get_post_meta($post_id, 'property_source_id', true); $query_api = get_option('bizdex_uplisting_api_key', ''); $ical_calendar_url = bizdex_uplisting_get_post_meta($post_id, 'ical_calendar_url', true); $ical_embed_code_url = bizdex_uplisting_get_post_meta($post_id, 'ical_embed_code_url', true); $ical_link = bizdex_uplisting_get_post_meta($post_id, 'ical_link', true); if ($booking_option === 'booking_form') { if (!empty($property_source_id) && !empty($query_api)) { return $this->sync_property_busy_dates_api($post_id); } bizdex_uplisting_log("Property {$post_id}: booking_form but API credentials missing"); return false; } if ($booking_option === 'uplisting_booking_form' || $booking_option === 'booking_button_only') { $ical_url = !empty($ical_calendar_url) ? $ical_calendar_url : $ical_link; if (!empty($ical_url) && filter_var($ical_url, FILTER_VALIDATE_URL)) { return $this->sync_property_busy_dates_ical($post_id); } bizdex_uplisting_log("Property {$post_id}: {$booking_option} but ical_calendar_url missing or invalid"); return false; } if ($booking_option === 'embed_code') { $ical_url = !empty($ical_embed_code_url) ? $ical_embed_code_url : $ical_link; if (!empty($ical_url) && filter_var($ical_url, FILTER_VALIDATE_URL)) { return $this->sync_property_busy_dates_ical($post_id); } bizdex_uplisting_log("Property {$post_id}: embed_code but ical_embed_code_url missing or invalid"); return false; } if (!empty($property_source_id) && !empty($query_api)) { return $this->sync_property_busy_dates_api($post_id); } $fallback_ical = $this->get_property_ical_link($post_id); if (!empty($fallback_ical)) { return $this->sync_property_busy_dates_ical($post_id); } bizdex_uplisting_log("No sync method available for property {$post_id}"); return false; } /** * Sync busy dates via the Uplisting API using wp_remote_get. * * @param int $post_id * @return bool */ public function sync_property_busy_dates_api($post_id) { $property_source_id = bizdex_uplisting_get_post_meta($post_id, 'property_source_id', true); $query_api = get_option('bizdex_uplisting_api_key', ''); if (empty($property_source_id) || empty($query_api)) { bizdex_uplisting_log("Missing API credentials for property {$post_id}"); return false; } $start_date = gmdate('Y-m-d'); $end_date = gmdate('Y-m-d', strtotime('+1 year')); $client = new UplistingClient($query_api); $data = $client->get_calendar($property_source_id, $start_date, $end_date); if (is_wp_error($data)) { bizdex_uplisting_log("API connection failed for property {$post_id}: " . $data->get_error_message()); return false; } $data = json_decode(json_encode($data)); if ($data && isset($data->calendar->days) && !empty($data->calendar->days)) { $busy_dates = []; foreach ($data->calendar->days as $day) { if (!$day->available && !$day->available_count) { $busy_dates[] = $day->date; } } sort($busy_dates); update_post_meta($post_id, bizdex_uplisting_meta_key('api_busy_dates'), $busy_dates); update_post_meta($post_id, bizdex_uplisting_meta_key('api_busy_dates_count'), count($busy_dates)); update_post_meta($post_id, bizdex_uplisting_meta_key('api_last_sync'), current_time('mysql')); update_post_meta($post_id, bizdex_uplisting_meta_key('sync_method'), 'api'); bizdex_uplisting_log("Found " . count($busy_dates) . " busy dates for property {$post_id}"); return true; } bizdex_uplisting_log("Invalid API response for property {$post_id}"); return false; } /** * Store today's actual nightly rate from a calendar response. * * @param array|\WP_Error $calendar_response */ private function save_listing_price_from_calendar(int $post_id, $calendar_response): bool { if (is_wp_error($calendar_response)) { return false; } if (!is_array($calendar_response)) { if (!is_object($calendar_response)) { return false; } $calendar_response = json_decode(wp_json_encode($calendar_response), true); } if (!is_array($calendar_response)) { return false; } $today = wp_date('Y-m-d'); $day_rate = BookingDetails::get_calendar_day_rate_for_date($calendar_response, $today); if ($day_rate <= 0) { return false; } update_post_meta($post_id, bizdex_uplisting_meta_key('uplisting_calendar_day_price'), $day_rate); update_post_meta($post_id, bizdex_uplisting_meta_key('uplisting_calendar_day_price_date'), $today); return true; } /** * Resolve the iCal link for a property. * * @param int $post_id * @return string */ public function get_property_ical_link($post_id) { $debug_log = []; $booking_option = bizdex_uplisting_get_post_meta($post_id, 'booking_option', true); $debug_log[] = "Post #{$post_id} booking_option: " . ($booking_option ?: '(empty)'); $ical_link = bizdex_uplisting_get_post_meta($post_id, 'ical_link', true); $ical_calendar_url = bizdex_uplisting_get_post_meta($post_id, 'ical_calendar_url', true); $ical_embed_code_url = bizdex_uplisting_get_post_meta($post_id, 'ical_embed_code_url', true); if ($booking_option === 'embed_code' && !empty($ical_embed_code_url)) { $ical_link = $ical_embed_code_url; $debug_log[] = 'Using embed_code iCal URL'; } elseif (!empty($ical_calendar_url)) { $ical_link = $ical_calendar_url; $debug_log[] = 'Using calendar iCal URL'; } if (empty($ical_link)) { $alternatives = ['ical_url', 'ical_feed', 'calendar_url', 'calendar_feed', 'calendar_ical', 'ical_calendar', 'availability_feed']; foreach ($alternatives as $field) { $val = bizdex_uplisting_get_post_meta($post_id, $field); if (!empty($val)) { $ical_link = $val; $debug_log[] = "Found alternative field {$field}"; break; } } } if (is_array($ical_link) && isset($ical_link['url'])) { $ical_link = $ical_link['url']; } $final_link = is_string($ical_link) ? trim($ical_link) : ''; $debug_log[] = $final_link ? "FINAL: {$final_link}" : 'ERROR: No iCal link found'; update_post_meta($post_id, bizdex_uplisting_meta_key('_ical_debug_log'), $debug_log); return $final_link; } /** * Sync busy dates from an iCal feed. * * @param int $post_id * @return bool */ public function sync_property_busy_dates_ical($post_id) { $ical_link = $this->get_property_ical_link($post_id); if (empty($ical_link) || !filter_var($ical_link, FILTER_VALIDATE_URL)) { bizdex_uplisting_log("Invalid/missing iCal URL for property {$post_id}"); return false; } try { $events = $this->fetchIcalData($ical_link); if ($events === false) { bizdex_uplisting_log("Failed to fetch iCal for property {$post_id}"); return false; } $disabled_dates = []; foreach ($events as $event) { if (!empty($event['start_date']) && !empty($event['end_date'])) { $range = $this->getDateRangeArray( gmdate('Y-m-d', strtotime($event['start_date'])), gmdate('Y-m-d', strtotime($event['end_date'])) ); $disabled_dates = array_merge($disabled_dates, $range); } } $disabled_dates = array_values(array_unique($disabled_dates)); sort($disabled_dates); update_post_meta($post_id, bizdex_uplisting_meta_key('ical_busy_dates'), $disabled_dates); update_post_meta($post_id, bizdex_uplisting_meta_key('ical_busy_dates_count'), count($disabled_dates)); update_post_meta($post_id, bizdex_uplisting_meta_key('ical_last_sync'), current_time('mysql')); update_post_meta($post_id, bizdex_uplisting_meta_key('sync_method'), 'ical'); return true; } catch (\Exception $e) { bizdex_uplisting_log('iCal sync error for property ' . $post_id . ': ' . $e->getMessage()); return false; } } /** * Fetch and parse an iCal feed URL using WordPress HTTP API. * * @param string $url * @return array|false */ public function fetchIcalData($url) { $url = esc_url_raw((string) $url); if ($url === '' || !wp_http_validate_url($url)) { return false; } $response = wp_safe_remote_get($url, [ 'timeout' => 30, 'user-agent' => 'Mozilla/5.0 (compatible; Uplisting-Connector)', ]); if (is_wp_error($response)) { bizdex_uplisting_log("fetchIcalData error: " . $response->get_error_message()); return false; } $ical_content = wp_remote_retrieve_body($response); if (empty($ical_content)) { bizdex_uplisting_log("fetchIcalData: empty response from {$url}"); return false; } return $this->parseIcalData($ical_content); } /** * Parse raw iCal content into an array of events. * * @param string $ical_string * @return array */ public function parseIcalData($ical_string) { $events = []; $lines = explode("\n", $ical_string); $current_event = null; foreach ($lines as $line) { $line = trim($line); if (strpos($line, 'BEGIN:VEVENT') === 0) { $current_event = []; } elseif (strpos($line, 'END:VEVENT') === 0) { if ($current_event !== null) { $events[] = $current_event; $current_event = null; } } elseif ($current_event !== null && strpos($line, ':') !== false) { [$property, $value] = explode(':', $line, 2); $key = $property; $timezone = null; if (strpos($property, ';') !== false) { $parts = explode(';', $property); $key = $parts[0]; foreach (array_slice($parts, 1) as $param) { if (strpos($param, 'TZID=') === 0) { $timezone = substr($param, 5); } } } switch ($key) { case 'SUMMARY': $current_event['title'] = $value; break; case 'DTSTART': $current_event['start_date'] = $this->formatIcalDate($value, $timezone); break; case 'DTEND': $current_event['end_date'] = $this->formatIcalDate($value, $timezone); break; case 'UID': $current_event['uid'] = $value; break; } } } return $events; } /** * Convert an iCal date string to Y-m-d H:i:s. * * @param string $ical_date * @param string|null $timezone * @return string */ public function formatIcalDate($ical_date, $timezone = null) { $ical_date = trim($ical_date); try { $tz = $timezone ? new \DateTimeZone($timezone) : null; if (strlen($ical_date) === 8) { // YYYYMMDD $date = \DateTime::createFromFormat('Ymd', $ical_date, $tz ?: new \DateTimeZone('UTC')); return $date ? $date->format('Y-m-d') : $ical_date; } if (strlen($ical_date) >= 15) { // YYYYMMDDTHHmmss[Z] $format = (substr($ical_date, -1) === 'Z') ? 'Ymd\THis\Z' : 'Ymd\THis'; $date = \DateTime::createFromFormat($format, $ical_date, new \DateTimeZone('UTC')); if ($date) { if ($tz) { $date->setTimezone($tz); } return $date->format('Y-m-d H:i:s'); } } return $ical_date; } catch (\Exception $e) { return $ical_date; } } /** * Return an array of Y-m-d date strings from $start_date up to (not including) $end_date. * * @param string $start_date Y-m-d * @param string $end_date Y-m-d * @return array */ public function getDateRangeArray($start_date, $end_date) { $dates = []; try { $start = new \DateTime($start_date); $end = new \DateTime($end_date); while ($start < $end) { $dates[] = $start->format('Y-m-d'); $start->add(new \DateInterval('P1D')); } } catch (\Exception $e) { bizdex_uplisting_log("getDateRangeArray error: " . $e->getMessage()); } return $dates; } }