'text/plain']; if ($method !== 'token') { $token = self::getApiToken(); $headers = [ 'Content-Type' => 'application/json', 'Authorization' => 'Bearer '.$token, ]; } $args = [ 'method' => $type, 'headers' => $headers, 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null, 'timeout' => 120, 'redirection' => 10, 'httpversion' => '1.1', 'blocking' => true, 'user-agent' => $_SERVER['HTTP_USER_AGENT'], ]; $response = $type === 'GET' ? wp_remote_get($url, $args) : wp_remote_post($url, $args); if (is_wp_error($response)) { return $response->get_error_message(); } else { $body = wp_remote_retrieve_body($response); $toReturn = json_decode($body, true); if (json_last_error() !== JSON_ERROR_NONE) { return 'JSON decode error: ' . json_last_error_msg(); } return $toReturn; } } /** * Retrieve the API token * * @return string The API token. */ private static function getApiToken() { global $mlsimport; return $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient(); } /** * Api Request to MLSimport API * * @param string $method The API method to call. * @param array $valuesArray The values to pass to the API. * @param string $type The request type (default is 'GET'). * @return array The API response data. */ /** * Fire-and-forget POST to the SaaS API. Refreshes the JWT token (blocking — a * required separate request); returns false without sending if the token is * unavailable. Otherwise issues wp_remote_post() with blocking=false, timeout=0.01 * and returns true. The response is never inspected. * * @param string $method The API method/path to call. * @param array $valuesArray The request body data. * @return bool True if dispatched, false if token unavailable. */ public static function globalApiRequestSaasFireAndForget( string $method, array $valuesArray ): bool { if ( ! self::validateAndRefreshToken() ) { return false; } $token = self::getApiToken(); wp_remote_post( MLSIMPORT_API_URL . $method, [ 'method' => 'POST', 'timeout' => 0.01, 'blocking' => false, 'headers' => [ 'Authorization' => 'Bearer ' . $token, 'Content-Type' => 'application/json', ], 'body' => wp_json_encode( $valuesArray ), ] ); return true; } public static function globalApiRequestSaas($method, $valuesArray, $type = 'GET') { global $mlsimport; // Skip validation for token and mls requests if ($method !== 'token' && $method !== 'mls') { if (!self::validateAndRefreshToken()) { return [ 'success' => false, 'error_message' => 'Token validation failed' ]; } } $url = MLSIMPORT_API_URL . $method; $headers = []; if ($method !== 'token' && $method !== 'mls') { $token = self::getApiToken(); $headers = [ 'Authorization' => 'Bearer '.$token, 'Content-Type' => 'application/json', ]; } $args = [ 'method' => $type, 'timeout' => 45, 'redirection' => 5, 'httpversion' => '1.0', 'blocking' => true, 'headers' => $headers, 'cookies' => [], 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null, ]; $response = wp_remote_post($url, $args); if (is_wp_error($response)) { return [ 'success' => false, 'error_code' => $response->get_error_code(), 'error_message' => esc_html($response->get_error_message()) ]; } $status_code = isset($response['response']['code']) ? intval($response['response']['code']) : 0; $body = wp_remote_retrieve_body($response); if (200 === $status_code) { $receivedData = json_decode($body, true); return $receivedData; } $error_message = 'Unknown error'; $error_code = $status_code; $decoded_body = json_decode($body, true); if (json_last_error() === JSON_ERROR_NONE && is_array($decoded_body)) { if (isset($decoded_body['error']['message'])) { $error_message = $decoded_body['error']['message']; if (isset($decoded_body['error']['code'])) { $error_code = $decoded_body['error']['code']; } } elseif (isset($decoded_body['message'])) { $error_message = $decoded_body['message']; } } return [ 'success' => false, 'error_code' => $error_code, 'error_message' => esc_html($error_message), ]; exit(); } /** * Check if token is expired and refresh if needed * Call this before any external API request * * @return bool True if token is valid, false if refresh failed */ private static function validateAndRefreshToken() { global $mlsimport; // Get stored expiry timestamp $token_expiry = get_option('mlsimport_token_expiry', 0); $current_time = time(); // Check if token is expired if ($current_time >= $token_expiry) { // Token expired, refresh it $refresh_result = self::refreshToken(); if (!$refresh_result) { return false; } } return true; } private static function refreshToken() { global $mlsimport; // Get credentials for token request $options = get_option('mlsimport_admin_options'); $username = isset($options['mlsimport_username']) ? $options['mlsimport_username'] : ''; $password = isset($options['mlsimport_password']) ? $options['mlsimport_password'] : ''; if (empty($username) || empty($password)) { mlsimport_telemetry_bump( 'token_failures' ); return false; } // Prepare token request $url = MLSIMPORT_API_URL . 'token'; $body = wp_json_encode(array( 'username' => $username, 'password' => $password )); $args = array( 'method' => 'POST', 'headers' => array( 'Content-Type' => 'application/json' ), 'body' => $body, 'timeout' => 45 ); // Make token request $response = wp_remote_post($url, $args); if (is_wp_error($response)) { mlsimport_telemetry_bump( 'token_failures' ); return false; } $body = wp_remote_retrieve_body($response); $data = json_decode($body, true); if (!isset($data['success']) || !$data['success'] || !isset($data['token']) || !isset($data['expires'])) { mlsimport_telemetry_bump( 'token_failures' ); return false; } // Store new token and expiry //$mlsimport->admin->mlsimport_saas_store_mls_api_token_transient($data['token']); $expires_in = $data['expires'] - time(); set_transient('mlsimport_saas_token', $data['token'], $expires_in); update_option('mlsimport_token_expiry', intval($data['expires'])); // First successful SaaS account connection (lifecycle telemetry). mlsimport_telemetry_set_once( 'account_connected_at', time() ); return true; } /** * * @param array $readyToParseArray The array ready to be parsed. * @param array $itemIdArray The item ID array. * @param string $batchKey The batch key. * @param array $mlsimportItemOptionData The item option data. */ public function mlsimportSaasParseSearchArrayPerItem($readyToParseArray, $itemIdArray, $batchKey, $mlsimportItemOptionData) { // Start with aggressive memory cleanup $this->cleanUpMemory(true); // Log initial memory usage $initialMemory = memory_get_usage(true); $counterProp = 0; $processedData = []; if (isset($readyToParseArray['data']) && is_array($readyToParseArray['data'])) { // Log total items to process $totalItems = count($readyToParseArray['data']); // Only keep essential data in memory, discard the rest foreach ($readyToParseArray['data'] as $key => $property) { // Save only what's needed from each property if (isset($property['ListingKey'])) { $processedData[$key] = $property; } // Remove from original array to free memory unset($readyToParseArray['data'][$key]); } // Complete unset of the original array unset($readyToParseArray); $this->cleanUpMemory(); $mlsimportItemId = intval($itemIdArray['item_id']); $current_prop_value = (int) get_post_meta( $mlsimportItemId, 'mlsimport_progress_properties', true ); // Process each property foreach ($processedData as $key => $property) { ++$counterProp; // Memory usage before processing property $memoryBefore = memory_get_usage(true); $memoryBeforeMB = round($memoryBefore / 1048576, 2); $listingKey = isset($property['ListingKey']) ? $property['ListingKey'] : 'unknown'; // Clear out database caches that might be polluted wp_cache_delete('mlsimport_force_stop_' . $itemIdArray['item_id'], 'options'); $GLOBALS['wpdb']->queries = array(); $status = get_option('mlsimport_force_stop_' . $itemIdArray['item_id']); if ($status === 'no') { $current_prop_value = $current_prop_value + 1; update_post_meta( $mlsimportItemId, 'mlsimport_progress_properties', $current_prop_value ); // Process property and track memory $this->mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, 'normal', $mlsimportItemOptionData); // Memory after processing property $memoryAfter = memory_get_usage(true); $memoryAfterMB = round($memoryAfter / 1048576, 2); $memoryDiff = round(($memoryAfter - $memoryBefore) / 1048576, 2); // Check for memory leak pattern if ($memoryDiff > 10) { // Force cleanup on large increases $this->cleanUpMemory(true); } // Aggressively clean after each property unset($property); // Periodic more intensive cleanup if ($counterProp % 3 == 0) { $this->cleanUpMemory(true); // Free database query cache $GLOBALS['wpdb']->flush(); // Clear autoloaded options cache, which can grow large wp_cache_delete('alloptions', 'options'); // Log memory after cleanup $memoryAfterCleanup = memory_get_usage(true); $freedMemory = round(($memoryAfter - $memoryAfterCleanup) / 1048576, 2); } } else { update_post_meta($itemIdArray['item_id'], 'mlsimport_spawn_status', 'completed'); break; } // Clear property from processed data to free memory unset($processedData[$key]); } } else { } // Final cleanup unset($processedData); $this->cleanUpMemory(true); // Log final memory stats $finalMemory = memory_get_usage(true); $finalMemoryMB = round($finalMemory / 1048576, 2); $totalMemoryDiff = round(($finalMemory - $initialMemory) / 1048576, 2); $peakMemory = round(memory_get_peak_usage(true) / 1048576, 2); } /** * Comprehensive memory cleanup function * * @param bool $intensive Whether to perform intensive cleanup */ private function cleanUpMemory($intensive = false) { // Basic cleanup wp_cache_flush(); gc_collect_cycles(); if ($intensive) { // Clear WordPress object cache global $wp_object_cache; if (is_object($wp_object_cache) && method_exists($wp_object_cache, 'flush')) { $wp_object_cache->flush(); } // Clear WordPress post caches clean_post_cache(0); // Safe term cache clearing - avoid SQL errors wp_cache_delete('get_terms', 'terms'); wp_cache_delete('term_meta', 'terms'); delete_option('category_children'); // Clear taxonomy-specific caches for common taxonomies $taxonomies = array('category', 'post_tag', 'property_status', 'property_type', 'property_feature', 'property_label', 'property_area', 'property_city', 'property_state', 'property_neighborhood'); foreach ($taxonomies as $taxonomy) { wp_cache_delete($taxonomy . '_relationships', 'terms'); } // Clear WordPress database cache global $wpdb; if (is_object($wpdb)) { $wpdb->queries = array(); if (method_exists($wpdb, 'flush')) { $wpdb->flush(); } } // Multiple garbage collection passes can sometimes help gc_collect_cycles(); gc_collect_cycles(); } } /** * Write logs for import process * * @param string $logs The log message to write. * @param string $type The type of log. */ private function writeImportLogs($logs, $type) { mlsimport_saas_single_write_import_custom_logs($logs, $type); } /** * Get memory usage * * @return string The memory usage in MB. */ public function mlsimportMemUsage() { $memUsage = memory_get_usage(true); $memUsageShow = round($memUsage / 1048576, 2); return $memUsageShow . 'mb '; } /** * Parse and import property data for a single MLSimport item in CRON. * Logs memory usage for each significant operation. * * @param array $readyToParseArray The array with listing data (from API). * @param array $itemIdArray The array with current MLSimport item info. * @param string $batchKey The batch identifier for logging. */ public function mlsimportSaasCronParseSearchArrayPerItem($readyToParseArray, $itemIdArray, $batchKey) { // Gather relevant meta for this MLSimport item $mlsimportItemOptionData = [ 'mlsimport_item_standardstatus' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_standardstatus', true), 'mlsimport_item_standardstatusprotect' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_standardstatusprotect', true), 'mlsimport_item_property_user' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_property_user', true), 'mlsimport_item_agent' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_agent', true), 'mlsimport_item_property_status' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_property_status', true), ]; $count = isset($readyToParseArray['data']) && is_array($readyToParseArray['data']) ? count($readyToParseArray['data']) : 0; $log = '[Memory] Start batch ' . $batchKey . ' with ' . $count . ' listings: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB'; $this->writeImportLogs($log, 'cron'); if ($count === 0) { $this->writeImportLogs('[Memory] No data to parse in batch ' . $batchKey, 'cron'); return; } foreach ($readyToParseArray['data'] as $key => $property) { // Log at the start of each property (optional, comment out if too verbose) //$log = '[Memory] Before import property #' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB'; //$this->writeImportLogs($log, 'cron'); $logs = 'In CRON parse search array, listing no ' . $key . ' from batch ' . $batchKey . ' with ListingKey: ' . $property['ListingKey'] . PHP_EOL; $this->writeImportLogs($logs, 'cron'); // Main per-property import function (handles mapping/import/update) $this->mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, 'cron', $mlsimportItemOptionData); // Clean up per-iteration memory unset($property); if (($key + 1) % 20 === 0) { gc_collect_cycles(); $log = '[Memory] After importing ' . ($key + 1) . ' listings in batch ' . $batchKey . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB'; $this->writeImportLogs($log, 'cron'); } } // Final memory log for this batch $this->writeImportLogs('[Memory] End batch ' . $batchKey . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB', 'cron'); // Housekeeping unset($readyToParseArray, $mlsimportItemOptionData); gc_collect_cycles(); } /** * Check if property already imported * * @param string $key The key to search for. * @param string $postType The post type to search within (default is 'estate_property'). * @return int The post ID if found, or 0 if not found. */ public function mlsimportSaasRetrievePropertyById($key, $postType = 'estate_property') { $args = [ 'post_type' => $postType, 'post_status' => 'any', 'meta_query' => [ [ 'key' => 'ListingKey', 'value' => $key, 'compare' => '=', ], ], 'fields' => 'ids', ]; $query = new WP_Query($args); if ($query->have_posts()) { $query->the_post(); $propertyId = get_the_ID(); wp_reset_postdata(); return $propertyId; } else { wp_reset_postdata(); return 0; } } /** * Clear taxonomy * * @param int $propertyId The property ID. * @param array $taxonomies The taxonomies to clear. */ public function mlsimportSaasClearPropertyForTaxonomy($propertyId, $taxonomies) { if (is_array($taxonomies)) { foreach ($taxonomies as $taxonomy => $term) { if (is_wp_error($taxonomy)) { continue; // Skip this iteration } if (taxonomy_exists($taxonomy)) { wp_delete_object_term_relationships($propertyId, $taxonomy); } else { } } } } /** * Set taxonomy for property * * @param string $taxonomy The taxonomy to set. * @param int $propertyId The property ID. * @param mixed $fieldValues The values to set. */ public function mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $fieldValues) { global $wpdb; // Convert comma-separated values to array if necessary if (!is_array($fieldValues)) { $fieldValues = strpos($fieldValues, ',') !== false ? explode(',', $fieldValues) : [$fieldValues]; } // Trim values and remove empty ones $fieldValues = array_filter(array_map('trim', $fieldValues)); // Start a database transaction $wpdb->query('START TRANSACTION'); $taxLog = []; foreach (array_chunk($fieldValues, 5) as $chunk) { foreach ($chunk as $value) { if (!empty($value)) { // Check if the term already exists $term = $wpdb->get_row($wpdb->prepare( "SELECT t.*, tt.* FROM $wpdb->terms t INNER JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id WHERE t.name = %s AND tt.taxonomy = %s", $value, $taxonomy )); $taxLog[] = json_encode($term); if (is_null($term)) { // Insert the term if it doesn't exist $wpdb->insert($wpdb->terms, [ 'name' => $value, 'slug' => sanitize_title($value), 'term_group' => 0 ]); $termId = $wpdb->insert_id; if ($termId) { // Insert term taxonomy $wpdb->insert($wpdb->term_taxonomy, [ 'term_id' => $termId, 'taxonomy' => $taxonomy, 'description' => '', 'parent' => 0, 'count' => 0 ]); $termTaxonomyId = $wpdb->insert_id; } else { $taxLog[] = 'Error inserting term'; continue; } } else { // Term exists, get term_id and term_taxonomy_id $termId = $term->term_id; $termTaxonomyId = $wpdb->get_var($wpdb->prepare( "SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE term_id = %d AND taxonomy = %s", $termId, $taxonomy )); } if (!empty($termTaxonomyId)) { // Insert term relationship $wpdb->replace($wpdb->term_relationships, [ 'object_id' => $propertyId, 'term_taxonomy_id' => $termTaxonomyId ]); // Increment the term count $wpdb->query($wpdb->prepare( "UPDATE $wpdb->term_taxonomy SET count = count + 1 WHERE term_taxonomy_id = %d", $termTaxonomyId )); } else { $taxLog[] = 'Error: term_taxonomy_id is null'; } } } // Flush the cache to free up memory wp_cache_flush(); // Run garbage collection gc_collect_cycles(); } // Commit the transaction $wpdb->query('COMMIT'); // Clear term cache selectively wp_cache_delete("{$taxonomy}_terms", 'terms'); wp_cache_delete("{$taxonomy}_children", 'terms'); // Restore the term metadata filter add_filter('get_term_metadata', [$wpdb->terms, 'cache_term_counts'], 10, 2); // Log memory usage // if (!empty($taxLog)) { // $taxLogStr = implode(PHP_EOL, $taxLog); // mlsimport_saas_single_write_import_custom_logs($taxLogStr, 'normal'); // unset($taxLogStr); // } } /** * Set Property Title * * @param int $propertyId The property ID. * @param int $mlsImportPostId The MLS import post ID. * @param array $property The property data. * @return string The updated title format. */ public function mlsimportSaasUpdatePropertyTitle($propertyId, $mlsImportPostId, $property) { global $mlsimport; $titleFormat = esc_html(get_post_meta($mlsImportPostId, 'mlsimport_item_title_format', true)); if ('' === $titleFormat) { $options = get_option('mlsimport_admin_mls_sync'); $titleFormat = $options['title_format']; } $titleArray = $this->strBetweenAll($titleFormat, '{', '}'); $propertyExtraMetaArrayLowerCase = array_change_key_case($property['extra_meta'], CASE_LOWER); foreach ($titleArray as $key => $value) { $replace = ''; switch ($value) { case 'Address': $replace = $property['adr_title'] ?? ''; break; case 'City': $replace = $property['adr_city'] ?? ''; break; case 'CountyOrParish': $replace = $property['adr_county'] ?? ''; break; case 'PropertyType': $replace = $property['adr_type'] ?? ''; break; case 'Bedrooms': $replace = $property['adr_bedrooms'] ?? ''; break; case 'Bathrooms': $replace = $property['adr_bathrooms'] ?? ''; break; case 'ListingKey': $replace = $property['ListingKey']; break; case 'ListingId': $replace = $property['adr_listingid'] ?? ''; break; case 'StateOrProvince': $replace = $property['extra_meta']['StateOrProvince'] ?? ''; break; case 'PostalCode': $replace = $property['meta']['property_zip'] ?? $property['meta']['fave_property_zip'] ?? ''; $replace = is_array($replace) ? strval($replace[0]) : strval($replace); break; case 'StreetNumberNumeric': $replace = $propertyExtraMetaArrayLowerCase['streetnumbernumeric'] ?? ''; break; case 'StreetName': $replace = $propertyExtraMetaArrayLowerCase['streetname'] ?? ''; break; } $titleFormat = str_replace('{' . $value . '}', $replace, $titleFormat); } $post = [ 'ID' => $propertyId, 'post_title' => $titleFormat, 'post_name' => $titleFormat, ]; wp_update_post($post); return $titleFormat; } /** * Prepare meta data for property * * @param array $property The property data. * @return array The property data with prepared meta. */ public function mlsimportSaasPrepareMetaForProperty($property) { $bathroomsRaw = $property['extra_meta']['BathroomsTotalDecimal'] ?? ''; $bathrooms = ( '' === $bathroomsRaw || null === $bathroomsRaw ) ? '' : floatval($bathroomsRaw); $property['meta']['property_bathrooms'] = $bathrooms; $property['meta']['fave_property_bathrooms'] = $bathrooms; $property['meta']['REAL_HOMES_property_bathrooms'] = $bathrooms; // PostalCode is commonly provided in normalized meta (property_zip) rather than extra_meta. // Mirror it into extra_meta when missing so field mappings (postmeta/taxonomy) can process it. if (!isset($property['extra_meta']) || !is_array($property['extra_meta'])) { $property['extra_meta'] = array(); } $postal_code = ''; if (isset($property['meta']) && is_array($property['meta'])) { if (!empty($property['meta']['property_zip'])) { $postal_code = $property['meta']['property_zip']; } elseif (!empty($property['meta']['fave_property_zip'])) { $postal_code = $property['meta']['fave_property_zip']; } elseif (!empty($property['meta']['REAL_HOMES_property_zip'])) { $postal_code = $property['meta']['REAL_HOMES_property_zip']; } } if (is_array($postal_code)) { $postal_code = reset($postal_code); } $postal_code = trim((string) $postal_code); if ('' !== $postal_code && empty($property['extra_meta']['PostalCode'])) { $property['extra_meta']['PostalCode'] = $postal_code; } return $property; } /** * Attach media to post * * @param int $propertyId The property ID. * @param array $media The media data. * @param string $isInsert Whether the property is being inserted. * @return string The media history log. */ public function mlsimportSassAttachMediaToPost($propertyId, $media, $isInsert,$media_attachments,$featuredImageKey, $shouldRefreshMedia = false) { $mediaHistory = []; if ($isInsert === 'no' && !$shouldRefreshMedia) { $mediaHistory[] = 'Media - We have edit - images are not replaced'; return $media_attachments; } global $mlsimport; include_once ABSPATH . 'wp-admin/includes/image.php'; $hasFeatured = false; add_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']); if (is_array($media)) { foreach ($media as $key=>$image) { if (isset($image['MediaCategory']) && $image['MediaCategory'] !== 'Property Photo' && $image['MediaCategory'] !== 'Photo') { continue; } if ( empty( $image['MediaURL'] ) ) { continue; } if (isset($image['MediaURL'])) { $file = $image['MediaURL']; $attachment = [ 'guid' => $file, 'post_status' => 'inherit', 'post_content' => '', 'post_parent' => $propertyId, 'post_mime_type' => $image['MimeType'] ?? 'image/jpeg', 'post_title' => $image['MediaKey'] ?? '', ]; $attachId = wp_insert_attachment($attachment, $file); if (is_wp_error($attachId)) { } else { $mediaHistory[] = 'Media - Added ' . $file . ' as attachment ' . $attachId; $media_attachments[]=$attachId; $mlsimport->admin->env_data->enviroment_image_save($propertyId, $attachId); update_post_meta($attachId, 'is_mlsimport', 1); if ($key===$featuredImageKey){ set_post_thumbnail($propertyId, $attachId); } else { } } } else { } } } else { $mediaHistory[] = 'Media data is blank - there are no images'; } remove_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']); return $media_attachments; //return implode('
', $mediaHistory); } /** * Unset image sizes * * @param array $sizes The sizes to unset. * @return array The modified sizes array. */ public function wpcUnsetImageSizes($sizes) { return []; } /** * Return user option * * @param int $selected The selected user ID. * @return string The HTML option elements for users. */ public function mlsimportSaasThemeImportSelectUser($selected) { $userOptions = ''; $blogusers = get_users(['blog_id' => 1, 'orderby' => 'nicename']); foreach ($blogusers as $user) { $userOptions .= ''; } return $userOptions; } /** * Return agent option * * @param int $selected The selected agent ID. * @return string The HTML option elements for agents. */ public function mlsimportSaasThemeImportSelectAgent($selected) { global $mlsimport; $args = [ 'post_type' => $mlsimport->admin->env_data->get_agent_post_type(), 'post_status' => 'publish', 'posts_per_page' => 150, ]; $agentSelection = new WP_Query($args); $agentOptions = ''; while ($agentSelection->have_posts()) { $agentSelection->the_post(); $agentId = get_the_ID(); $agentOptions .= '