PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 6.3.8
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v6.3.8
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
mlsimport / includes / ThemeImport.php

ThemeImport.php in MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings 6.3.8, at includes/ThemeImport.php

2,233 lines 74.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 if ( ! defined( 'ABSPATH' ) ) {
3 exit; // Exit if accessed directly
4 }
5
6 /**
7 * Description of ThemeImport
8 *
9 * @class ThemeImport
10 */
11 class ThemeImport {
12
13
14 public $theme;
15 public $plugin_name;
16 public $enviroment;
17 public $encoded_values;
18
19
20 /**
21 * Api Request to MLSimport API using CURL
22 *
23 * @param string $method The API method to call.
24 * @param array $values_array The values to pass to the API.
25 * @param string $type The request type (default is 'GET').
26 * @return mixed The API response or error message.
27 */
28
29 public function globalApiRequestCurlSaas($method, $valuesArray, $type = 'GET') {
30
31
32 global $mlsimport;
33
34 // Skip validation for token requests
35 if ($method !== 'token') {
36 if (!self::validateAndRefreshToken()) {
37 return 'Token validation failed';
38 }
39 }
40
41 $url = MLSIMPORT_API_URL . $method;
42 $headers = ['Content-Type' => 'text/plain'];
43
44 if ($method !== 'token') {
45 $token = self::getApiToken();
46 $headers = [
47 'Content-Type' => 'application/json',
48 'Authorization' => 'Bearer '.$token,
49 ];
50 }
51
52 $args = [
53 'method' => $type,
54 'headers' => $headers,
55 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null,
56 'timeout' => 120,
57 'redirection' => 10,
58 'httpversion' => '1.1',
59 'blocking' => true,
60 'user-agent' => $_SERVER['HTTP_USER_AGENT'],
61 ];
62
63
64 $response = $type === 'GET' ? wp_remote_get($url, $args) : wp_remote_post($url, $args);
65
66
67
68 if (is_wp_error($response)) {
69 return $response->get_error_message();
70 } else {
71 $body = wp_remote_retrieve_body($response);
72
73 $toReturn = json_decode($body, true);
74 if (json_last_error() !== JSON_ERROR_NONE) {
75 return 'JSON decode error: ' . json_last_error_msg();
76 }
77 return $toReturn;
78 }
79 }
80
81
82 /**
83 * Retrieve the API token
84 *
85 * @return string The API token.
86 */
87 private static function getApiToken() {
88 global $mlsimport;
89 return $mlsimport->admin->mlsimport_saas_get_mls_api_token_from_transient();
90 }
91
92
93 /**
94 * Api Request to MLSimport API
95 *
96 * @param string $method The API method to call.
97 * @param array $valuesArray The values to pass to the API.
98 * @param string $type The request type (default is 'GET').
99 * @return array The API response data.
100 */
101
102 /**
103 * Fire-and-forget POST to the SaaS API. Refreshes the JWT token (blocking — a
104 * required separate request); returns false without sending if the token is
105 * unavailable. Otherwise issues wp_remote_post() with blocking=false, timeout=0.01
106 * and returns true. The response is never inspected.
107 *
108 * @param string $method The API method/path to call.
109 * @param array $valuesArray The request body data.
110 * @return bool True if dispatched, false if token unavailable.
111 */
112 public static function globalApiRequestSaasFireAndForget( string $method, array $valuesArray ): bool {
113 if ( ! self::validateAndRefreshToken() ) {
114 return false;
115 }
116
117 $token = self::getApiToken();
118
119 wp_remote_post(
120 MLSIMPORT_API_URL . $method,
121 [
122 'method' => 'POST',
123 'timeout' => 0.01,
124 'blocking' => false,
125 'headers' => [
126 'Authorization' => 'Bearer ' . $token,
127 'Content-Type' => 'application/json',
128 ],
129 'body' => wp_json_encode( $valuesArray ),
130 ]
131 );
132
133 return true;
134 }
135
136
137 public static function globalApiRequestSaas($method, $valuesArray, $type = 'GET') {
138 global $mlsimport;
139 // Skip validation for token and mls requests
140 if ($method !== 'token' && $method !== 'mls') {
141 if (!self::validateAndRefreshToken()) {
142 return [
143 'success' => false,
144 'error_message' => 'Token validation failed'
145 ];
146 }
147 }
148
149
150 $url = MLSIMPORT_API_URL . $method;
151
152 $headers = [];
153 if ($method !== 'token' && $method !== 'mls') {
154 $token = self::getApiToken();
155 $headers = [
156 'Authorization' => 'Bearer '.$token,
157 'Content-Type' => 'application/json',
158 ];
159 }
160
161
162 $args = [
163 'method' => $type,
164 'timeout' => 45,
165 'redirection' => 5,
166 'httpversion' => '1.0',
167 'blocking' => true,
168 'headers' => $headers,
169 'cookies' => [],
170 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null,
171 ];
172 $response = wp_remote_post($url, $args);
173
174
175
176 if (is_wp_error($response)) {
177 return [
178 'success' => false,
179 'error_code' => $response->get_error_code(),
180 'error_message' => esc_html($response->get_error_message())
181 ];
182 }
183
184 $status_code = isset($response['response']['code']) ? intval($response['response']['code']) : 0;
185 $body = wp_remote_retrieve_body($response);
186
187 if (200 === $status_code) {
188 $receivedData = json_decode($body, true);
189 return $receivedData;
190 }
191
192 $error_message = 'Unknown error';
193 $error_code = $status_code;
194
195 $decoded_body = json_decode($body, true);
196 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded_body)) {
197 if (isset($decoded_body['error']['message'])) {
198 $error_message = $decoded_body['error']['message'];
199 if (isset($decoded_body['error']['code'])) {
200 $error_code = $decoded_body['error']['code'];
201 }
202 } elseif (isset($decoded_body['message'])) {
203 $error_message = $decoded_body['message'];
204 }
205 }
206
207 return [
208 'success' => false,
209 'error_code' => $error_code,
210 'error_message' => esc_html($error_message),
211 ];
212
213 exit();
214 }
215
216
217
218 /**
219 * Check if token is expired and refresh if needed
220 * Call this before any external API request
221 *
222 * @return bool True if token is valid, false if refresh failed
223 */
224 private static function validateAndRefreshToken() {
225 global $mlsimport;
226
227 // Get stored expiry timestamp
228 $token_expiry = get_option('mlsimport_token_expiry', 0);
229 $current_time = time();
230
231 // Check if token is expired
232 if ($current_time >= $token_expiry) {
233 // Token expired, refresh it
234 $refresh_result = self::refreshToken();
235
236 if (!$refresh_result) {
237 return false;
238 }
239 }
240
241 return true;
242 }
243
244 private static function refreshToken() {
245 global $mlsimport;
246
247 // Get credentials for token request
248 $options = get_option('mlsimport_admin_options');
249 $username = isset($options['mlsimport_username']) ? $options['mlsimport_username'] : '';
250 $password = isset($options['mlsimport_password']) ? $options['mlsimport_password'] : '';
251
252 if (empty($username) || empty($password)) {
253 mlsimport_telemetry_bump( 'token_failures' );
254 return false;
255 }
256
257 // Prepare token request
258 $url = MLSIMPORT_API_URL . 'token';
259 $body = wp_json_encode(array(
260 'username' => $username,
261 'password' => $password
262 ));
263
264 $args = array(
265 'method' => 'POST',
266 'headers' => array(
267 'Content-Type' => 'application/json'
268 ),
269 'body' => $body,
270 'timeout' => 45
271 );
272
273 // Make token request
274 $response = wp_remote_post($url, $args);
275
276 if (is_wp_error($response)) {
277 mlsimport_telemetry_bump( 'token_failures' );
278 return false;
279 }
280
281 $body = wp_remote_retrieve_body($response);
282 $data = json_decode($body, true);
283
284 if (!isset($data['success']) || !$data['success'] || !isset($data['token']) || !isset($data['expires'])) {
285 mlsimport_telemetry_bump( 'token_failures' );
286 return false;
287 }
288
289 // Store new token and expiry
290 //$mlsimport->admin->mlsimport_saas_store_mls_api_token_transient($data['token']);
291
292 $expires_in = $data['expires'] - time();
293 set_transient('mlsimport_saas_token', $data['token'], $expires_in);
294
295 update_option('mlsimport_token_expiry', intval($data['expires']));
296
297 // First successful SaaS account connection (lifecycle telemetry).
298 mlsimport_telemetry_set_once( 'account_connected_at', time() );
299
300 return true;
301 }
302
303
304 /**
305 *
306 * @param array $readyToParseArray The array ready to be parsed.
307 * @param array $itemIdArray The item ID array.
308 * @param string $batchKey The batch key.
309 * @param array $mlsimportItemOptionData The item option data.
310 */
311 public function mlsimportSaasParseSearchArrayPerItem($readyToParseArray, $itemIdArray, $batchKey, $mlsimportItemOptionData) {
312 // Start with aggressive memory cleanup
313 $this->cleanUpMemory(true);
314
315 // Log initial memory usage
316 $initialMemory = memory_get_usage(true);
317
318 $counterProp = 0;
319 $processedData = [];
320
321 if (isset($readyToParseArray['data']) && is_array($readyToParseArray['data'])) {
322 // Log total items to process
323 $totalItems = count($readyToParseArray['data']);
324
325
326
327
328
329 // Only keep essential data in memory, discard the rest
330 foreach ($readyToParseArray['data'] as $key => $property) {
331
332
333
334 // Save only what's needed from each property
335 if (isset($property['ListingKey'])) {
336 $processedData[$key] = $property;
337 }
338 // Remove from original array to free memory
339 unset($readyToParseArray['data'][$key]);
340 }
341
342 // Complete unset of the original array
343 unset($readyToParseArray);
344 $this->cleanUpMemory();
345
346
347 $mlsimportItemId = intval($itemIdArray['item_id']);
348
349 $current_prop_value = (int) get_post_meta( $mlsimportItemId, 'mlsimport_progress_properties', true );
350
351
352 // Process each property
353 foreach ($processedData as $key => $property) {
354 ++$counterProp;
355
356 // Memory usage before processing property
357 $memoryBefore = memory_get_usage(true);
358 $memoryBeforeMB = round($memoryBefore / 1048576, 2);
359
360 $listingKey = isset($property['ListingKey']) ? $property['ListingKey'] : 'unknown';
361
362 // Clear out database caches that might be polluted
363 wp_cache_delete('mlsimport_force_stop_' . $itemIdArray['item_id'], 'options');
364 $GLOBALS['wpdb']->queries = array();
365
366 $status = get_option('mlsimport_force_stop_' . $itemIdArray['item_id']);
367
368 if ($status === 'no') {
369
370
371 $current_prop_value = $current_prop_value + 1;
372 update_post_meta( $mlsimportItemId, 'mlsimport_progress_properties', $current_prop_value );
373
374
375
376 // Process property and track memory
377 $this->mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, 'normal', $mlsimportItemOptionData);
378
379 // Memory after processing property
380 $memoryAfter = memory_get_usage(true);
381 $memoryAfterMB = round($memoryAfter / 1048576, 2);
382 $memoryDiff = round(($memoryAfter - $memoryBefore) / 1048576, 2);
383
384
385 // Check for memory leak pattern
386 if ($memoryDiff > 10) {
387 // Force cleanup on large increases
388 $this->cleanUpMemory(true);
389 }
390
391 // Aggressively clean after each property
392 unset($property);
393
394 // Periodic more intensive cleanup
395 if ($counterProp % 3 == 0) {
396 $this->cleanUpMemory(true);
397
398 // Free database query cache
399 $GLOBALS['wpdb']->flush();
400
401 // Clear autoloaded options cache, which can grow large
402 wp_cache_delete('alloptions', 'options');
403
404 // Log memory after cleanup
405 $memoryAfterCleanup = memory_get_usage(true);
406 $freedMemory = round(($memoryAfter - $memoryAfterCleanup) / 1048576, 2);
407 }
408 } else {
409 update_post_meta($itemIdArray['item_id'], 'mlsimport_spawn_status', 'completed');
410 break;
411 }
412
413 // Clear property from processed data to free memory
414 unset($processedData[$key]);
415 }
416 } else {
417 }
418
419 // Final cleanup
420 unset($processedData);
421 $this->cleanUpMemory(true);
422
423 // Log final memory stats
424 $finalMemory = memory_get_usage(true);
425 $finalMemoryMB = round($finalMemory / 1048576, 2);
426 $totalMemoryDiff = round(($finalMemory - $initialMemory) / 1048576, 2);
427 $peakMemory = round(memory_get_peak_usage(true) / 1048576, 2);
428
429 }
430
431
432 /**
433 * Comprehensive memory cleanup function
434 *
435 * @param bool $intensive Whether to perform intensive cleanup
436 */
437 private function cleanUpMemory($intensive = false) {
438 // Basic cleanup
439 wp_cache_flush();
440 gc_collect_cycles();
441
442 if ($intensive) {
443 // Clear WordPress object cache
444 global $wp_object_cache;
445 if (is_object($wp_object_cache) && method_exists($wp_object_cache, 'flush')) {
446 $wp_object_cache->flush();
447 }
448
449 // Clear WordPress post caches
450 clean_post_cache(0);
451
452 // Safe term cache clearing - avoid SQL errors
453 wp_cache_delete('get_terms', 'terms');
454 wp_cache_delete('term_meta', 'terms');
455 delete_option('category_children');
456
457 // Clear taxonomy-specific caches for common taxonomies
458 $taxonomies = array('category', 'post_tag', 'property_status', 'property_type', 'property_feature', 'property_label', 'property_area', 'property_city', 'property_state', 'property_neighborhood');
459 foreach ($taxonomies as $taxonomy) {
460 wp_cache_delete($taxonomy . '_relationships', 'terms');
461 }
462
463 // Clear WordPress database cache
464 global $wpdb;
465 if (is_object($wpdb)) {
466 $wpdb->queries = array();
467 if (method_exists($wpdb, 'flush')) {
468 $wpdb->flush();
469 }
470 }
471
472 // Multiple garbage collection passes can sometimes help
473 gc_collect_cycles();
474 gc_collect_cycles();
475 }
476 }
477
478
479
480
481
482
483
484 /**
485 * Write logs for import process
486 *
487 * @param string $logs The log message to write.
488 * @param string $type The type of log.
489 */
490 private function writeImportLogs($logs, $type) {
491 mlsimport_saas_single_write_import_custom_logs($logs, $type);
492 }
493
494 /**
495 * Get memory usage
496 *
497 * @return string The memory usage in MB.
498 */
499 public function mlsimportMemUsage() {
500 $memUsage = memory_get_usage(true);
501 $memUsageShow = round($memUsage / 1048576, 2);
502 return $memUsageShow . 'mb ';
503 }
504
505
506
507
508
509
510 /**
511 * Parse and import property data for a single MLSimport item in CRON.
512 * Logs memory usage for each significant operation.
513 *
514 * @param array $readyToParseArray The array with listing data (from API).
515 * @param array $itemIdArray The array with current MLSimport item info.
516 * @param string $batchKey The batch identifier for logging.
517 */
518 public function mlsimportSaasCronParseSearchArrayPerItem($readyToParseArray, $itemIdArray, $batchKey) {
519 // Gather relevant meta for this MLSimport item
520 $mlsimportItemOptionData = [
521 'mlsimport_item_standardstatus' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_standardstatus', true),
522 'mlsimport_item_standardstatusprotect' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_standardstatusprotect', true),
523 'mlsimport_item_property_user' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_property_user', true),
524 'mlsimport_item_agent' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_agent', true),
525 'mlsimport_item_property_status' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_property_status', true),
526 ];
527
528 $count = isset($readyToParseArray['data']) && is_array($readyToParseArray['data']) ? count($readyToParseArray['data']) : 0;
529 $log = '[Memory] Start batch ' . $batchKey . ' with ' . $count . ' listings: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB';
530 $this->writeImportLogs($log, 'cron');
531
532 if ($count === 0) {
533 $this->writeImportLogs('[Memory] No data to parse in batch ' . $batchKey, 'cron');
534 return;
535 }
536
537 foreach ($readyToParseArray['data'] as $key => $property) {
538 // Log at the start of each property (optional, comment out if too verbose)
539 //$log = '[Memory] Before import property #' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB';
540 //$this->writeImportLogs($log, 'cron');
541
542 $logs = 'In CRON parse search array, listing no ' . $key . ' from batch ' . $batchKey . ' with ListingKey: ' . $property['ListingKey'] . PHP_EOL;
543 $this->writeImportLogs($logs, 'cron');
544
545 // Main per-property import function (handles mapping/import/update)
546 $this->mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, 'cron', $mlsimportItemOptionData);
547
548 // Clean up per-iteration memory
549 unset($property);
550 if (($key + 1) % 20 === 0) {
551 gc_collect_cycles();
552 $log = '[Memory] After importing ' . ($key + 1) . ' listings in batch ' . $batchKey . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB';
553 $this->writeImportLogs($log, 'cron');
554 }
555 }
556 // Final memory log for this batch
557 $this->writeImportLogs('[Memory] End batch ' . $batchKey . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB', 'cron');
558
559 // Housekeeping
560 unset($readyToParseArray, $mlsimportItemOptionData);
561 gc_collect_cycles();
562 }
563
564
565
566
567
568
569
570
571
572
573
574 /**
575 * Check if property already imported
576 *
577 * @param string $key The key to search for.
578 * @param string $postType The post type to search within (default is 'estate_property').
579 * @return int The post ID if found, or 0 if not found.
580 */
581 public function mlsimportSaasRetrievePropertyById($key, $postType = 'estate_property') {
582 $args = [
583 'post_type' => $postType,
584 'post_status' => 'any',
585 'meta_query' => [
586 [
587 'key' => 'ListingKey',
588 'value' => $key,
589 'compare' => '=',
590 ],
591 ],
592 'fields' => 'ids',
593 ];
594
595 $query = new WP_Query($args);
596 if ($query->have_posts()) {
597 $query->the_post();
598 $propertyId = get_the_ID();
599 wp_reset_postdata();
600 return $propertyId;
601 } else {
602 wp_reset_postdata();
603 return 0;
604 }
605 }
606
607
608
609
610 /**
611 * Clear taxonomy
612 *
613 * @param int $propertyId The property ID.
614 * @param array $taxonomies The taxonomies to clear.
615 */
616 public function mlsimportSaasClearPropertyForTaxonomy($propertyId, $taxonomies) {
617 if (is_array($taxonomies)) {
618 foreach ($taxonomies as $taxonomy => $term) {
619 if (is_wp_error($taxonomy)) {
620
621 continue; // Skip this iteration
622 }
623
624 if (taxonomy_exists($taxonomy)) {
625 wp_delete_object_term_relationships($propertyId, $taxonomy);
626 } else {
627 }
628 }
629 }
630 }
631
632
633
634
635
636 /**
637 * Set taxonomy for property
638 *
639 * @param string $taxonomy The taxonomy to set.
640 * @param int $propertyId The property ID.
641 * @param mixed $fieldValues The values to set.
642 */
643 public function mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $fieldValues) {
644 global $wpdb;
645
646 // Convert comma-separated values to array if necessary
647 if (!is_array($fieldValues)) {
648 $fieldValues = strpos($fieldValues, ',') !== false ? explode(',', $fieldValues) : [$fieldValues];
649 }
650
651 // Trim values and remove empty ones
652 $fieldValues = array_filter(array_map('trim', $fieldValues));
653
654 // Start a database transaction
655 $wpdb->query('START TRANSACTION');
656 $taxLog = [];
657
658 foreach (array_chunk($fieldValues, 5) as $chunk) {
659 foreach ($chunk as $value) {
660 if (!empty($value)) {
661 // Check if the term already exists
662 $term = $wpdb->get_row($wpdb->prepare(
663 "SELECT t.*, tt.* FROM $wpdb->terms t
664 INNER JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id
665 WHERE t.name = %s AND tt.taxonomy = %s",
666 $value, $taxonomy
667 ));
668
669 $taxLog[] = json_encode($term);
670 if (is_null($term)) {
671 // Insert the term if it doesn't exist
672 $wpdb->insert($wpdb->terms, [
673 'name' => $value,
674 'slug' => sanitize_title($value),
675 'term_group' => 0
676 ]);
677
678 $termId = $wpdb->insert_id;
679
680 if ($termId) {
681 // Insert term taxonomy
682 $wpdb->insert($wpdb->term_taxonomy, [
683 'term_id' => $termId,
684 'taxonomy' => $taxonomy,
685 'description' => '',
686 'parent' => 0,
687 'count' => 0
688 ]);
689
690 $termTaxonomyId = $wpdb->insert_id;
691 } else {
692 $taxLog[] = 'Error inserting term';
693 continue;
694 }
695 } else {
696 // Term exists, get term_id and term_taxonomy_id
697 $termId = $term->term_id;
698 $termTaxonomyId = $wpdb->get_var($wpdb->prepare(
699 "SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE term_id = %d AND taxonomy = %s",
700 $termId, $taxonomy
701 ));
702 }
703
704 if (!empty($termTaxonomyId)) {
705 // Insert term relationship
706 $wpdb->replace($wpdb->term_relationships, [
707 'object_id' => $propertyId,
708 'term_taxonomy_id' => $termTaxonomyId
709 ]);
710 // Location taxonomies (browse-by-city/area widgets) use a publish-aware
711 // recompute so the displayed count matches the publish-only archive.
712 // A blind +1 is not idempotent (re-imports inflate it) and ignores
713 // post status, so a new city could show a count while its archive is
714 // empty. These terms are low-cardinality (tens of listings), so the
715 // COUNT is cheap. High-cardinality grouping taxonomies keep the O(1)
716 // increment to avoid scanning thousands of rows per import.
717 $location_taxonomies = array('property_city', 'property_area', 'property_state', 'property_neighborhood');
718 if (in_array($taxonomy, $location_taxonomies, true)) {
719 // Mirrors WordPress' _update_post_term_count callback in SQL.
720 $wpdb->query($wpdb->prepare(
721 "UPDATE $wpdb->term_taxonomy tt
722 SET count = (
723 SELECT COUNT(*) FROM $wpdb->term_relationships tr
724 INNER JOIN $wpdb->posts p ON p.ID = tr.object_id
725 WHERE tr.term_taxonomy_id = tt.term_taxonomy_id
726 AND p.post_status = 'publish'
727 )
728 WHERE tt.term_taxonomy_id = %d",
729 $termTaxonomyId
730 ));
731 } else {
732 $wpdb->query($wpdb->prepare(
733 "UPDATE $wpdb->term_taxonomy SET count = count + 1 WHERE term_taxonomy_id = %d",
734 $termTaxonomyId
735 ));
736 }
737 } else {
738 $taxLog[] = 'Error: term_taxonomy_id is null';
739 }
740 }
741 }
742 // Flush the cache to free up memory
743 wp_cache_flush();
744 // Run garbage collection
745 gc_collect_cycles();
746 }
747 // Commit the transaction
748 $wpdb->query('COMMIT');
749
750 // Clear term cache selectively
751 wp_cache_delete("{$taxonomy}_terms", 'terms');
752 wp_cache_delete("{$taxonomy}_children", 'terms');
753
754 // Restore the term metadata filter
755 add_filter('get_term_metadata', [$wpdb->terms, 'cache_term_counts'], 10, 2);
756
757 // Log memory usage
758 // if (!empty($taxLog)) {
759 // $taxLogStr = implode(PHP_EOL, $taxLog);
760 // mlsimport_saas_single_write_import_custom_logs($taxLogStr, 'normal');
761 // unset($taxLogStr);
762 // }
763 }
764
765
766
767
768 /**
769 * Set Property Title
770 *
771 * @param int $propertyId The property ID.
772 * @param int $mlsImportPostId The MLS import post ID.
773 * @param array $property The property data.
774 * @return string The updated title format.
775 */
776 public function mlsimportSaasUpdatePropertyTitle($propertyId, $mlsImportPostId, $property) {
777 global $mlsimport;
778
779 $titleFormat = esc_html(get_post_meta($mlsImportPostId, 'mlsimport_item_title_format', true));
780
781 if ('' === $titleFormat) {
782 $options = get_option('mlsimport_admin_mls_sync');
783 $titleFormat = $options['title_format'];
784 }
785
786 $titleArray = $this->strBetweenAll($titleFormat, '{', '}');
787
788 $propertyExtraMetaArrayLowerCase = array_change_key_case($property['extra_meta'], CASE_LOWER);
789
790 foreach ($titleArray as $key => $value) {
791 $replace = '';
792 switch ($value) {
793 case 'Address':
794 $replace = $property['adr_title'] ?? '';
795 break;
796 case 'City':
797 $replace = $property['adr_city'] ?? '';
798 break;
799 case 'CountyOrParish':
800 $replace = $property['adr_county'] ?? '';
801 break;
802 case 'PropertyType':
803 $replace = $property['adr_type'] ?? '';
804 break;
805 case 'Bedrooms':
806 $replace = $property['adr_bedrooms'] ?? '';
807 break;
808 case 'Bathrooms':
809 $replace = $property['adr_bathrooms'] ?? '';
810 break;
811 case 'ListingKey':
812 $replace = $property['ListingKey'];
813 break;
814 case 'ListingId':
815 $replace = $property['adr_listingid'] ?? '';
816 break;
817 case 'StateOrProvince':
818 $replace = $property['extra_meta']['StateOrProvince'] ?? '';
819 break;
820 case 'PostalCode':
821 $replace = $property['meta']['property_zip'] ?? $property['meta']['fave_property_zip'] ?? '';
822 $replace = is_array($replace) ? strval($replace[0]) : strval($replace);
823 break;
824 case 'StreetNumberNumeric':
825 $replace = $propertyExtraMetaArrayLowerCase['streetnumbernumeric'] ?? '';
826 break;
827 case 'StreetName':
828 $replace = $propertyExtraMetaArrayLowerCase['streetname'] ?? '';
829 break;
830 }
831 $titleFormat = str_replace('{' . $value . '}', $replace, $titleFormat);
832 }
833
834 $post = [
835 'ID' => $propertyId,
836 'post_title' => $titleFormat,
837 'post_name' => $titleFormat,
838 ];
839
840 wp_update_post($post);
841
842 return $titleFormat;
843 }
844
845
846
847
848
849
850 /**
851 * Prepare meta data for property
852 *
853 * @param array $property The property data.
854 * @return array The property data with prepared meta.
855 */
856 public function mlsimportSaasPrepareMetaForProperty($property) {
857 // BathroomsTotalDecimal is not provided by every MLS (e.g. BrightMLS sends only
858 // BathroomsTotalInteger / BathroomsFull). Fall back so the theme Overview value
859 // is not wiped to empty.
860 $bathroomsRaw = $property['extra_meta']['BathroomsTotalDecimal']
861 ?? $property['extra_meta']['BathroomsTotalInteger']
862 ?? $property['extra_meta']['BathroomsFull']
863 ?? '';
864 $bathrooms = ( '' === $bathroomsRaw || null === $bathroomsRaw ) ? '' : floatval($bathroomsRaw);
865 $property['meta']['property_bathrooms'] = $bathrooms;
866 $property['meta']['fave_property_bathrooms'] = $bathrooms;
867 $property['meta']['REAL_HOMES_property_bathrooms'] = $bathrooms;
868
869 // PostalCode is commonly provided in normalized meta (property_zip) rather than extra_meta.
870 // Mirror it into extra_meta when missing so field mappings (postmeta/taxonomy) can process it.
871 if (!isset($property['extra_meta']) || !is_array($property['extra_meta'])) {
872 $property['extra_meta'] = array();
873 }
874
875 $postal_code = '';
876 if (isset($property['meta']) && is_array($property['meta'])) {
877 if (!empty($property['meta']['property_zip'])) {
878 $postal_code = $property['meta']['property_zip'];
879 } elseif (!empty($property['meta']['fave_property_zip'])) {
880 $postal_code = $property['meta']['fave_property_zip'];
881 } elseif (!empty($property['meta']['REAL_HOMES_property_zip'])) {
882 $postal_code = $property['meta']['REAL_HOMES_property_zip'];
883 }
884 }
885
886 if (is_array($postal_code)) {
887 $postal_code = reset($postal_code);
888 }
889 $postal_code = trim((string) $postal_code);
890
891 if ('' !== $postal_code && empty($property['extra_meta']['PostalCode'])) {
892 $property['extra_meta']['PostalCode'] = $postal_code;
893 }
894
895 return $property;
896 }
897
898
899
900
901
902 /**
903 * Attach media to post
904 *
905 * @param int $propertyId The property ID.
906 * @param array $media The media data.
907 * @param string $isInsert Whether the property is being inserted.
908 * @return string The media history log.
909 */
910 public function mlsimportSassAttachMediaToPost($propertyId, $media, $isInsert,$media_attachments,$featuredImageKey, $shouldRefreshMedia = false) {
911
912 $mediaHistory = [];
913
914 if ($isInsert === 'no' && !$shouldRefreshMedia) {
915 $mediaHistory[] = 'Media - We have edit - images are not replaced';
916 return $media_attachments;
917 }
918
919 global $mlsimport;
920 include_once ABSPATH . 'wp-admin/includes/image.php';
921 $hasFeatured = false;
922
923
924
925
926 add_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']);
927
928
929 if (is_array($media)) {
930 foreach ($media as $key=>$image) {
931 if (isset($image['MediaCategory']) && $image['MediaCategory'] !== 'Property Photo' && $image['MediaCategory'] !== 'Photo') {
932 continue;
933 }
934
935 if ( empty( $image['MediaURL'] ) ) {
936 continue;
937 }
938
939
940
941 if (isset($image['MediaURL'])) {
942 $file = $image['MediaURL'];
943 $attachment = [
944 'guid' => $file,
945 'post_status' => 'inherit',
946 'post_content' => '',
947 'post_parent' => $propertyId,
948 'post_mime_type' => $image['MimeType'] ?? 'image/jpeg',
949 'post_title' => $image['MediaKey'] ?? '',
950 ];
951
952
953 $attachId = wp_insert_attachment($attachment, $file);
954 // wp_insert_attachment() returns 0 (int) on failure, not a
955 // WP_Error, so is_wp_error() alone lets a failed insert through
956 // and records a 0 — which poisons the gallery and makes the
957 // featured-image fallback set_post_thumbnail($id, 0) a no-op.
958 if (is_wp_error($attachId) || ! $attachId) {
959 } else {
960 $mediaHistory[] = 'Media - Added ' . $file . ' as attachment ' . $attachId;
961 $media_attachments[]=$attachId;
962
963
964 $mlsimport->admin->env_data->enviroment_image_save($propertyId, $attachId);
965 update_post_meta($attachId, 'is_mlsimport', 1);
966
967 if ($key===$featuredImageKey){
968
969
970 set_post_thumbnail($propertyId, $attachId);
971
972 } else {
973 }
974 }
975 } else {
976 }
977 }
978 } else {
979 $mediaHistory[] = 'Media data is blank - there are no images';
980 }
981
982 remove_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']);
983
984 return $media_attachments;
985 //return implode('</br>', $mediaHistory);
986 }
987
988
989 /**
990 * Unset image sizes
991 *
992 * @param array $sizes The sizes to unset.
993 * @return array The modified sizes array.
994 */
995 public function wpcUnsetImageSizes($sizes) {
996 return [];
997 }
998
999
1000
1001
1002
1003
1004
1005 /**
1006 * Return user option
1007 *
1008 * @param int $selected The selected user ID.
1009 * @return string The HTML option elements for users.
1010 */
1011 public function mlsimportSaasThemeImportSelectUser($selected) {
1012 $userOptions = '';
1013 $blogusers = get_users(['blog_id' => 1, 'orderby' => 'nicename']);
1014 foreach ($blogusers as $user) {
1015 $userOptions .= '<option value="' . esc_attr($user->ID) . '"';
1016 if ($user->ID == $selected) {
1017 $userOptions .= ' selected="selected"';
1018 }
1019 $userOptions .= '>' . esc_html($user->user_login) . '</option>';
1020 }
1021 return $userOptions;
1022 }
1023
1024
1025
1026
1027
1028
1029
1030 /**
1031 * Return agent option
1032 *
1033 * @param int $selected The selected agent ID.
1034 * @return string The HTML option elements for agents.
1035 */
1036 public function mlsimportSaasThemeImportSelectAgent($selected) {
1037 global $mlsimport;
1038 $args = [
1039 'post_type' => $mlsimport->admin->env_data->get_agent_post_type(),
1040 'post_status' => 'publish',
1041 'posts_per_page' => 150,
1042 ];
1043
1044 $agentSelection = new WP_Query($args);
1045 $agentOptions = '<option value=""></option>';
1046
1047 while ($agentSelection->have_posts()) {
1048 $agentSelection->the_post();
1049 $agentId = get_the_ID();
1050
1051 $agentOptions .= '<option value="' . esc_attr($agentId) . '"';
1052 if ($agentId == $selected) {
1053 $agentOptions .= ' selected="selected"';
1054 }
1055 $agentOptions .= '>' . esc_html(get_the_title()) . '</option>';
1056 }
1057 wp_reset_postdata();
1058
1059 return $agentOptions;
1060 }
1061
1062
1063
1064
1065
1066
1067
1068
1069 /**
1070 * Delete property
1071 *
1072 * @param int $deleteId The ID of the property to delete.
1073 * @param string $ListingKey The listing key of the property.
1074 */
1075 public function deleteProperty($deleteId, $ListingKey) {
1076 if ($deleteId > 0) {
1077 mlsimport_record_activity( 'deleted', $deleteId, get_post_meta($deleteId,'ListingKey',true), intval(get_post_meta($deleteId,'MLSimport_item_inserted',true)), 'import' );
1078 $args = [
1079 'numberposts' => -1,
1080 'post_type' => 'attachment',
1081 'post_parent' => $deleteId,
1082 'post_status' => null,
1083 'orderby' => 'menu_order',
1084 'order' => 'ASC',
1085 ];
1086 $postAttachments = get_posts($args);
1087
1088 foreach ($postAttachments as $attachment) {
1089 wp_delete_post($attachment->ID);
1090 }
1091
1092 wp_delete_post($deleteId);
1093 mlsimport_telemetry_bump( 'deleted' );
1094 $logEntry = 'Property with id ' . $deleteId . ' and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
1095 $this->writeImportLogs($logEntry, 'delete');
1096 }
1097 }
1098
1099
1100
1101
1102 /**
1103 * Return array with title items
1104 *
1105 * @param string $string The input string.
1106 * @param string $start The start delimiter.
1107 * @param string $end The end delimiter.
1108 * @param bool $includeDelimiters Whether to include the delimiters in the result.
1109 * @param int $offset The offset to start searching from.
1110 * @return array The array of strings found between the delimiters.
1111 */
1112 public function strBetweenAll(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): array {
1113 $strings = [];
1114 $length = strlen($string);
1115
1116 while ($offset < $length) {
1117 $found = $this->strBetween($string, $start, $end, $includeDelimiters, $offset);
1118 if ($found === null) {
1119 break;
1120 }
1121
1122 $strings[] = $found;
1123 $offset += strlen($includeDelimiters ? $found : $start . $found . $end); // move offset to the end of the newfound string
1124 }
1125
1126 return $strings;
1127 }
1128
1129 /**
1130 * Find string between delimiters
1131 *
1132 * @param string $string The input string.
1133 * @param string $start The start delimiter.
1134 * @param string $end The end delimiter.
1135 * @param bool $includeDelimiters Whether to include the delimiters in the result.
1136 * @param int $offset The offset to start searching from.
1137 * @return string|null The string found between the delimiters, or null if not found.
1138 */
1139 public function strBetween(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): ?string {
1140 if ($string === '' || $start === '' || $end === '') {
1141 return null;
1142 }
1143
1144 $startLength = strlen($start);
1145 $endLength = strlen($end);
1146
1147 $startPos = strpos($string, $start, $offset);
1148 if ($startPos === false) {
1149 return null;
1150 }
1151
1152 $endPos = strpos($string, $end, $startPos + $startLength);
1153 if ($endPos === false) {
1154 return null;
1155 }
1156
1157 $length = $endPos - $startPos + ($includeDelimiters ? $endLength : -$startLength);
1158 if (!$length) {
1159 return '';
1160 }
1161
1162 $offset = $startPos + ($includeDelimiters ? 0 : $startLength);
1163
1164 return substr($string, $offset, $length);
1165 }
1166
1167
1168
1169
1170
1171 /**
1172 * Delete property via SQL
1173 *
1174 * @param int $deleteId The ID of the property to delete.
1175 * @param string $ListingKey The listing key of the property.
1176 */
1177 public function mlsimportSaasDeletePropertyViaMysql($deleteId, $ListingKey) {
1178 global $mlsimport;
1179
1180 $postType = get_post_type($deleteId);
1181 $propertyPostType = '';
1182 if (isset($mlsimport->admin->env_data) && method_exists($mlsimport->admin->env_data, 'get_property_post_type')) {
1183 $propertyPostType = $mlsimport->admin->env_data->get_property_post_type();
1184 }
1185
1186 if ($postType === $propertyPostType || in_array($postType, ['estate_property', 'property'])) {
1187 // Delete attachments using WordPress functions so the files are removed as well
1188 $attachments = get_posts([
1189 'numberposts' => -1,
1190 'post_type' => 'attachment',
1191 'post_parent' => $deleteId,
1192 'post_status' => null,
1193 'fields' => 'ids',
1194 ]);
1195
1196 foreach ($attachments as $attachmentId) {
1197 wp_delete_attachment($attachmentId, true);
1198 }
1199
1200 $termObjList = get_the_terms($deleteId, 'property_status');
1201 $deleteIdStatus = join(', ', wp_list_pluck($termObjList, 'name'));
1202
1203 $ListingKey = get_post_meta($deleteId, 'ListingKey', true);
1204 if ('' === $ListingKey) { // manually added listing
1205 $logEntry = 'User added listing with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' NOT DELETED' . PHP_EOL;
1206 $this->writeImportLogs($logEntry, 'delete');
1207 return;
1208 }
1209
1210 mlsimport_record_activity( 'deleted', $deleteId, $ListingKey, intval(get_post_meta($deleteId,'MLSimport_item_inserted',true)), 'reconciliation' );
1211
1212 global $wpdb;
1213 $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->postmeta WHERE `post_id` = %d", $deleteId));
1214 $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE `post_parent` = %d OR `ID` = %d", $deleteId, $deleteId));
1215 mlsimport_telemetry_bump( 'deleted' );
1216
1217 $logEntry = 'MYSQL DELETE -> Property with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
1218 $this->writeImportLogs($logEntry, 'delete');
1219 }
1220 }
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230 /**
1231 * Prepare to import per item
1232 *
1233 * @param array $property The property data.
1234 * @param array $itemIdArray The item ID array.
1235 * @param string $tipImport The import type.
1236 * @param array $mlsimportItemOptionData The item option data.
1237 */
1238 public function mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, $tipImport, $mlsimportItemOptionData) {
1239 // Pre-execution memory optimization
1240 wp_cache_flush();
1241 gc_collect_cycles();
1242
1243 // Temporarily disable WordPress hooks that might add to memory usage
1244 global $wp_filter;
1245 $saved_filters = array();
1246 if (isset($wp_filter['transition_post_status'])) {
1247 $saved_filters['transition_post_status'] = $wp_filter['transition_post_status'];
1248 unset($wp_filter['transition_post_status']);
1249 }
1250 if (isset($wp_filter['save_post'])) {
1251 $saved_filters['save_post'] = $wp_filter['save_post'];
1252 $wp_filter['save_post'] = new WP_Hook();
1253 }
1254 set_time_limit(0);
1255 global $mlsimport;
1256
1257 // Log initial memory
1258 $memStart = memory_get_usage(true);
1259 $memStartMB = round($memStart / 1048576, 2);
1260
1261 $mlsImportItemStatus = $mlsimportItemOptionData['mlsimport_item_standardstatus'];
1262 $newAuthor = $mlsimportItemOptionData['mlsimport_item_property_user'];
1263 $newAgent = $mlsimportItemOptionData['mlsimport_item_agent'];
1264 $propertyStatus = $mlsimportItemOptionData['mlsimport_item_property_status'];
1265
1266 if (is_array($mlsImportItemStatus)) {
1267 $mlsImportItemStatus = array_map('mlsimport_normalize_status_enum', $mlsImportItemStatus);
1268 }
1269
1270 if (!isset($property['ListingKey']) || empty($property['ListingKey'])) {
1271 $this->writeImportLogs('ERROR: No Listing Key ' . PHP_EOL, $tipImport);
1272 return;
1273 }
1274
1275 ob_start();
1276
1277 $ListingKey = $property['ListingKey'];
1278 $listingPostType = $mlsimport->admin->env_data->get_property_post_type();
1279
1280 // Memory before property ID lookup
1281 $memBeforeRetrieve = memory_get_usage(true);
1282
1283 $propertyId = intval($this->mlsimportSaasRetrievePropertyById($ListingKey, $listingPostType));
1284
1285 // Memory after property ID lookup
1286 $memAfterRetrieve = memory_get_usage(true);
1287
1288 $status = isset($property['StandardStatus']) ? mlsimport_normalize_status_enum($property['StandardStatus']) : mlsimport_normalize_status_enum($property['extra_meta']['MlsStatus']);
1289
1290 $this->writeImportLogs('FIxing: on inserting ' .$status.'-->'.json_encode($mlsImportItemStatus). PHP_EOL, $tipImport);
1291
1292 $isInsert = $this->shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport);
1293
1294 $log = $this->mlsimportMemUsage() . '==========' . wp_json_encode($mlsImportItemStatus) . '/' . $newAuthor . '/' . $newAgent . '/' . $propertyStatus . '/ We have property with $ListingKey=' . $ListingKey . ' id=' . $propertyId . ' with status ' . $status . ' is insert? ' . $isInsert . PHP_EOL;
1295 $this->writeImportLogs($log, $tipImport);
1296
1297 $propertyHistory = [];
1298 $content = $property['content'] ?? '';
1299 $submitTitle = $ListingKey;
1300
1301 // Memory before insert/update
1302 $memBeforeInsert = memory_get_usage(true);
1303
1304 $activityAction = '';
1305
1306 // Incoming MLS modification time (unix). The hourly delta sync uses a
1307 // rolling 2-hour overlap window, so the SAME listing is returned on
1308 // several consecutive runs. We compare this against the last value we
1309 // recorded (mlsimport_synced_mod) to count a listing as "edited" only
1310 // when it actually changed since our last import — not on every re-touch.
1311 $incomingMod = isset($property['extra_meta']['ModificationTimestamp'])
1312 ? strtotime((string) $property['extra_meta']['ModificationTimestamp'])
1313 : 0;
1314 if (false === $incomingMod) {
1315 $incomingMod = 0;
1316 }
1317
1318 if ($isInsert === 'yes') {
1319 $post = [
1320 'post_title' => $submitTitle,
1321 'post_content' => $content,
1322 'post_status' => $propertyStatus,
1323 'post_type' => $listingPostType,
1324 'post_author' => $newAuthor,
1325 ];
1326
1327 $propertyId = wp_insert_post($post);
1328
1329 if (is_wp_error($propertyId)) {
1330 $this->writeImportLogs('ERROR: on inserting ' . PHP_EOL, $tipImport);
1331 } else {
1332 update_post_meta($propertyId, 'ListingKey', $ListingKey);
1333 update_post_meta($propertyId, 'MLSimport_item_inserted', $itemIdArray['item_id'],);
1334 $activityAction = 'added';
1335 update_post_meta($propertyId, 'mlsimport_synced_mod', $incomingMod);
1336 $propertyHistory[] = date('F j, Y, g:i a') . ': We Inserted the property with Default title : ' . $submitTitle . ' and received id:' . $propertyId;
1337 mlsimport_telemetry_bump( 'imported' );
1338 }
1339
1340 clean_post_cache($propertyId);
1341
1342 } elseif ($propertyId !== 0) {
1343
1344
1345 // Memory before checking existing property
1346 $memBeforeCheck = memory_get_usage(true);
1347
1348 $keep = $this->shouldKeepExistingListing($status, $mlsImportItemStatus);
1349
1350
1351 if(!$keep){
1352 $log = 'Property with ID ' . $propertyId . ' and with name ' . get_the_title($propertyId) . ' has a status of <strong>' . $status . ' </strong> and will be deleted' . PHP_EOL;
1353
1354 // Memory before delete
1355 $memBeforeDelete = memory_get_usage(true);
1356
1357 $this->deleteProperty($propertyId, $ListingKey);
1358
1359 // Memory after delete
1360 $memAfterDelete = memory_get_usage(true);
1361
1362 $this->writeImportLogs($log, $tipImport);
1363 } else {
1364 // Memory before updating
1365 $memBeforeUpdate = memory_get_usage(true);
1366
1367 $propertyHistory = $this->updateExistingProperty($propertyId, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, $propertyHistory, $tipImport, $ListingKey);
1368
1369 // Count as "edited" only when the listing actually changed since our
1370 // last import. A missing incoming timestamp (0) means we can't tell,
1371 // so fall back to recording the edit.
1372 $storedMod = (int) get_post_meta($propertyId, 'mlsimport_synced_mod', true);
1373 if (0 === $incomingMod || $incomingMod > $storedMod) {
1374 $activityAction = 'edited';
1375 if ($incomingMod > 0) {
1376 update_post_meta($propertyId, 'mlsimport_synced_mod', $incomingMod);
1377 }
1378 }
1379
1380 // Memory after updating
1381 $memAfterUpdate = memory_get_usage(true);
1382 }
1383 }
1384
1385 // Memory after insert/update
1386 $memAfterInsert = memory_get_usage(true);
1387
1388 if ($propertyId === 0) {
1389 $this->writeImportLogs('ERROR property id is 0' . PHP_EOL, $tipImport);
1390 return;
1391 }
1392
1393 // Memory before processing details
1394 $memBeforeDetails = memory_get_usage(true);
1395
1396 $newTitle = $this->processPropertyDetails($property, $propertyId, $tipImport, $propertyHistory, $newAgent, $itemIdArray,$isInsert);
1397
1398 if ( $activityAction !== '' ) {
1399 mlsimport_record_activity( $activityAction, $propertyId, $ListingKey, isset($itemIdArray['item_id']) ? intval($itemIdArray['item_id']) : 0, $tipImport );
1400 }
1401
1402 // Memory after processing details
1403 $memAfterDetails = memory_get_usage(true);
1404
1405 $log = PHP_EOL . 'Ending on Property ' . $propertyId . ', ListingKey: ' . $ListingKey . ' , is insert? ' . $isInsert . ' with new title: ' . $newTitle . ' ' . PHP_EOL;
1406 $this->writeImportLogs($log, $tipImport);
1407
1408 clean_post_cache($propertyId);
1409
1410 // More aggressive memory cleanup
1411 // First clear specific large arrays in property data
1412 if (isset($property['Media']) && is_array($property['Media'])) {
1413 foreach ($property['Media'] as $key => $media) {
1414 unset($property['Media'][$key]);
1415 }
1416 }
1417 if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1418 foreach ($property['extra_meta'] as $key => $value) {
1419 unset($property['extra_meta'][$key]);
1420 }
1421 }
1422 if (isset($property['meta']) && is_array($property['meta'])) {
1423 foreach ($property['meta'] as $key => $value) {
1424 unset($property['meta'][$key]);
1425 }
1426 }
1427 if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1428 foreach ($property['taxonomies'] as $key => $value) {
1429 unset($property['taxonomies'][$key]);
1430 }
1431 }
1432
1433 // Then unset the main arrays
1434 unset($property['Media']);
1435 unset($property['extra_meta']);
1436 unset($property['meta']);
1437 unset($property['taxonomies']);
1438 unset($property);
1439
1440 // Clear any post caches that might have been created
1441 clean_post_cache($propertyId);
1442
1443 // Clear other variables that hold large data
1444 unset($log);
1445 unset($propertyHistory);
1446 $GLOBALS['wpdb']->queries = array();
1447
1448 // Clear WordPress specific caches
1449 wp_cache_delete('get_term_meta', 'terms');
1450 wp_cache_delete('terms', 'terms');
1451 wp_cache_delete('term_meta', 'terms');
1452 wp_cache_delete('get_terms', 'terms');
1453
1454 // Clear post related caches
1455 wp_cache_delete('post_meta_' . $propertyId, 'post_meta');
1456 wp_cache_delete($propertyId, 'posts');
1457
1458 // Force multiple garbage collection cycles
1459 gc_collect_cycles();
1460 gc_collect_cycles();
1461
1462 // Close and discard any output buffer content
1463 ob_end_clean();
1464
1465 // Try to trigger PHP's internal memory cleanup
1466 $dummy = str_repeat('x', 1024 * 1024);
1467 unset($dummy);
1468
1469 // Final memory usage
1470 $memEnd = memory_get_usage(true);
1471 $memEndMB = round($memEnd / 1048576, 2);
1472 $memDiff = round(($memEnd - $memStart) / 1048576, 2);
1473
1474 // If we see a significant memory increase, log a warning
1475 if ($memDiff > 5) {
1476 }
1477
1478 // Restore WordPress hooks
1479 global $wp_filter;
1480 if (!empty($saved_filters)) {
1481 foreach ($saved_filters as $hook => $filter) {
1482 $wp_filter[$hook] = $filter;
1483 }
1484 }
1485 }
1486
1487
1488
1489
1490
1491
1492 /**
1493 * Check if the property should be inserted
1494 *
1495 * @param int $propertyId The property ID.
1496 * @param string $status The property status.
1497 * @param array $mlsImportItemStatus The MLS import item status.
1498 * @param string $tipImport The import type.
1499 * @return string 'yes' or 'no' indicating if the property should be inserted.
1500 */
1501 private function shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport): string{
1502 $this->writeImportLogs(
1503 "Checking: on inserting {$propertyId}={$status} vs " .
1504 json_encode($mlsImportItemStatus) . " -- {$tipImport}" . PHP_EOL,
1505 $tipImport
1506 );
1507
1508
1509 if ($propertyId !== 0 || !is_array($mlsImportItemStatus)) {
1510 return 'no';
1511
1512 }
1513
1514 $activeStatuses = [
1515 'active',
1516 'active under contract',
1517 'active with contract',
1518 'activewithcontract',
1519 'status',
1520 'activeundercontract',
1521 'comingsoon',
1522 'coming soon',
1523 'pending'
1524 ];
1525 if(is_array($mlsImportItemStatus)){
1526 if (!in_array(strtolower($status), $mlsImportItemStatus, true)) {
1527 return 'no';
1528 }
1529
1530 if ($tipImport === 'cron' && !in_array($status, $mlsImportItemStatus, true)) {
1531 return 'no';
1532 }
1533
1534 }else{
1535 if(!in_array($status, $activeStatuses, true) ){
1536 return 'no';
1537 }
1538 }
1539
1540 return 'yes';
1541 }
1542
1543
1544 /**
1545 * Check for property status against MLS item delete status to see if we keep or delete the listing.
1546 * @param int $property_id
1547 * @param string|array $mlsImportItemStatus
1548 * @return bool True to keep, false to delete
1549 */
1550 public function check_if_delete_when_status($property_id, $mlsImportItemStatus, $mlsImportItemStatusDelete = null, $mlsImportItemStatusProtect = null) {
1551
1552 // Get post_status based on post type/taxonomy. The status taxonomy is
1553 // resolved from the user's StandardStatus field mapping (theme default as
1554 // fallback): a task can map status onto a different taxonomy than the
1555 // hardcoded default, and reading the wrong one returns an empty status that
1556 // wrongly deletes live listings during reconciliation.
1557 $post_status = '';
1558 $mlsimport_status_tax_map = ( $mlsimport_fields_opt = get_option('mlsimport_admin_fields_select') ) && isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
1559 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
1560 if (post_type_exists('estate_property')) {
1561 $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_status'));
1562 if (!empty($terms) && is_array($terms)) {
1563 $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1564 }
1565 } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1566 $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_label'));
1567 if (!empty($terms) && is_array($terms)) {
1568 $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1569 }
1570 } else {
1571 $post_status = mlsimport_normalize_status_enum(get_post_meta($property_id, 'inspiry_property_label', true));
1572 }
1573
1574 // Protected statuses: keep if property status matches
1575 if (!empty($mlsImportItemStatusProtect)) {
1576 $mlsImportItemStatusProtect = is_array($mlsImportItemStatusProtect)
1577 ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatusProtect)
1578 : array(mlsimport_normalize_status_enum($mlsImportItemStatusProtect));
1579 if (in_array($post_status, $mlsImportItemStatusProtect, true)) {
1580 return true;
1581 }
1582 }
1583
1584 // Default: delete if not protected
1585 return false;
1586 }
1587
1588
1589
1590
1591 public function check_if_delete_when_status_on_manual_import($property_id, $mlsImportItemStatus) {
1592 // Normalize status arrays/strings to a space-free comparison key so
1593 // Trestle PrettyEnums labels match the raw enum config values.
1594 $mlsImportItemStatus = is_array($mlsImportItemStatus)
1595 ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatus)
1596 : mlsimport_normalize_status_enum($mlsImportItemStatus);
1597
1598 // Get post_status based on post type/taxonomy. The status taxonomy is
1599 // resolved from the user's StandardStatus field mapping (theme default as
1600 // fallback): a task can map status onto a different taxonomy than the
1601 // hardcoded default, and reading the wrong one returns an empty status that
1602 // wrongly deletes live listings during reconciliation.
1603 $post_status = '';
1604 $mlsimport_status_tax_map = ( $mlsimport_fields_opt = get_option('mlsimport_admin_fields_select') ) && isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
1605 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
1606 if (post_type_exists('estate_property')) {
1607 $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_status'));
1608 if (!empty($terms) && is_array($terms)) {
1609 $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1610 }
1611 } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1612 $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_label'));
1613 if (!empty($terms) && is_array($terms)) {
1614 $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1615 }
1616 } else {
1617 $post_status = mlsimport_normalize_status_enum(get_post_meta($property_id, 'inspiry_property_label', true));
1618 }
1619
1620
1621
1622 // Keep if status matches "keep" status
1623 if ((is_array($mlsImportItemStatus) && in_array($post_status, $mlsImportItemStatus, true)) ||
1624 (!is_array($mlsImportItemStatus) && $post_status === $mlsImportItemStatus)) {
1625
1626 return true;
1627 }
1628
1629
1630
1631 // Default: keep
1632 return false;
1633 }
1634
1635
1636 /**
1637 * Decide whether to keep an existing listing the (filtered) feed returned again.
1638 *
1639 * Uses the live MLS status — the SAME basis shouldInsertProperty() uses to
1640 * decide an insert. The old code compared the stored property_status taxonomy
1641 * term instead, which can be empty, remapped, or theme-labeled; when it did not
1642 * equal the RESO status, keep said "delete" while insert said "yes", producing
1643 * an add/delete/add cycle on every sync. See issue #152.
1644 *
1645 * @param string $status Live MLS StandardStatus (lowercased).
1646 * @param array|string $mlsImportItemStatus Task's selected RESO statuses (lowercased).
1647 * @return bool True to keep/update, false to delete.
1648 */
1649 public function shouldKeepExistingListing($status, $mlsImportItemStatus): bool {
1650 return is_array($mlsImportItemStatus) && in_array($status, $mlsImportItemStatus, true);
1651 }
1652
1653
1654
1655
1656
1657 /**
1658 * Check if we should keep or delete the listing when still in MLS.
1659 * true we keep
1660 */
1661 public function check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect = null) {
1662 $post_status = '';
1663
1664 // Resolve the status taxonomy from the user's StandardStatus field
1665 // mapping (theme default as fallback) — see note in check_if_delete_when_status().
1666 $mlsimport_status_tax_map = ( $mlsimport_fields_opt = get_option('mlsimport_admin_fields_select') ) && isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
1667 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
1668
1669 // Check for post status based on post type/taxonomy
1670 if (post_type_exists('estate_property')) {
1671 // WPResidence
1672 $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_status'));
1673 if (!empty($terms) && is_array($terms)) {
1674 $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1675 }
1676 } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1677 // Houzez
1678 $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_label'));
1679 if (!empty($terms) && is_array($terms)) {
1680 $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1681 }
1682 } else {
1683 // RealHomes
1684 $post_status = mlsimport_normalize_status_enum(get_post_meta($property_id, 'inspiry_property_label', true));
1685 }
1686
1687 // Protected statuses: keep if property status matches
1688 if (!empty($mlsimport_item_standardstatusprotect)) {
1689 $mlsimport_item_standardstatusprotect = is_array($mlsimport_item_standardstatusprotect)
1690 ? array_map('mlsimport_normalize_status_enum', $mlsimport_item_standardstatusprotect)
1691 : array(mlsimport_normalize_status_enum($mlsimport_item_standardstatusprotect));
1692 if (in_array($post_status, $mlsimport_item_standardstatusprotect, true)) {
1693 return true;
1694 }
1695 }
1696
1697 // Early return if MLS status empty
1698 if (empty($mlsimport_item_standardstatus)) {
1699 return true; // default: keep if no status set
1700 }
1701
1702 // Normalize standard statuses to a space-free key for comparison
1703 if (is_array($mlsimport_item_standardstatus)) {
1704 $mlsimport_item_standardstatus = array_map('mlsimport_normalize_status_enum', $mlsimport_item_standardstatus);
1705 return in_array($post_status, $mlsimport_item_standardstatus, true);
1706 }
1707 return $post_status === mlsimport_normalize_status_enum($mlsimport_item_standardstatus);
1708 }
1709
1710
1711
1712
1713
1714
1715
1716 /**
1717 * Update existing property
1718 *
1719 * @param int $propertyId The property ID.
1720 * @param string $content The post content.
1721 * @param string $listingPostType The listing post type.
1722 * @param int $newAuthor The new author ID.
1723 * @param string $status The property status.
1724 * @param array $mlsImportItemStatus The MLS import item status.
1725 * @param array $propertyHistory The property history.
1726 * @param string $tipImport The import type.
1727 * @param string $ListingKey The listing key.
1728 * @return array Updated property history.
1729 */
1730 private function updateExistingProperty($propertyId, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, &$propertyHistory, $tipImport, $ListingKey) {
1731
1732
1733 $post = [
1734 'ID' => $propertyId,
1735 'post_content' => $content,
1736 'post_type' => $listingPostType,
1737 'post_author' => $newAuthor,
1738 ];
1739
1740 $log = 'Property with ID ' . $propertyId . ' and with name ' . get_the_title($propertyId) . ' has a status of <strong>' . $status . '</strong> and will be Edited</br>';
1741 $this->writeImportLogs($log, $tipImport);
1742
1743 $propertyId = wp_update_post($post);
1744 if (is_wp_error($propertyId)) {
1745 $this->writeImportLogs('ERROR: on edit ' . PHP_EOL, $tipImport);
1746 } else {
1747 $submitTitle = get_the_title($propertyId);
1748 $propertyHistory[] = gmdate('F j, Y, g:i a') . ': Property with title: ' . $submitTitle . ', id:' . $propertyId . ', ListingKey:' . $ListingKey . ', Status:' . $status . ' will be edited';
1749 mlsimport_telemetry_bump( 'updated' );
1750 }
1751 clean_post_cache( $propertyId );
1752
1753 return $propertyHistory;
1754 }
1755
1756 /**
1757 * Process property details with memory tracking and optimization
1758 *
1759 * @param array $property The property data.
1760 * @param int $propertyId The property ID.
1761 * @param string $tipImport The import type.
1762 * @param array $propertyHistory The property history.
1763 * @param int $newAgent The new agent ID.
1764 * @param array $itemIdArray The item ID array.
1765 * @param string $isInsert If is a property insert
1766 */
1767 private function processPropertyDetails($property, $propertyId, $tipImport, &$propertyHistory, $newAgent, $itemIdArray, $isInsert) {
1768 global $mlsimport, $wpdb;
1769
1770
1771
1772 // Normalize timestamp fields in extra_meta to format like "May 17, 2025 at 06:26am"
1773 if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1774 $timestampFields = [
1775 'StatusChangeTimestamp',
1776 'STELLAR_BOMDate',
1777 'PriceChangeTimestamp',
1778 'PhotosChangeTimestamp',
1779 'BridgeModificationTimestamp',
1780 'ModificationTimestamp',
1781 'OriginalEntryTimestamp',
1782 'MajorChangeTimestamp'
1783 ];
1784
1785 foreach ($timestampFields as $tsField) {
1786 if (!empty($property['extra_meta'][$tsField])) {
1787 $timestamp = strtotime($property['extra_meta'][$tsField]);
1788 if ($timestamp !== false) {
1789 $property['extra_meta'][$tsField] = gmdate('F j, Y \a\t h:ia', $timestamp);
1790 }
1791 }
1792 }
1793 }
1794
1795
1796
1797 // 1. DISABLE AUTOCOMMIT FOR BATCH PROCESSING
1798 // This reduces memory by preventing DB auto-commits between operations
1799 if (method_exists($wpdb, 'query')) {
1800 $wpdb->query('SET autocommit = 0');
1801 }
1802
1803 // 2. TEMPORARY DISABLE ACTIONS THAT CONSUME MEMORY
1804 $suspended_actions = [];
1805 foreach (['save_post', 'added_post_meta', 'updated_post_meta'] as $action) {
1806 if (has_action($action)) {
1807 $suspended_actions[$action] = true;
1808 remove_all_actions($action);
1809 }
1810 }
1811
1812 // Initial memory
1813 $memStart = memory_get_usage(true);
1814
1815 $log = PHP_EOL . $this->mlsimportMemUsage() . '====before tax======' . PHP_EOL;
1816 $this->writeImportLogs($log, $tipImport);
1817
1818 // 3. OPTIMIZE TAXONOMY PROCESSING
1819 if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1820 $memBeforeTax = memory_get_usage(true);
1821
1822 // Load taxonomy mapping options
1823 $options = get_option('mlsimport_admin_fields_select');
1824 $theme_schema = mlsimport_hardocde_theme_schema();
1825 $taxonomy_overrides = array();
1826 if (isset($options['mls-fields-map-taxonomy']) && is_array($options['mls-fields-map-taxonomy'])) {
1827 foreach ($options['mls-fields-map-taxonomy'] as $field_key => $mapped_tax) {
1828 if ($mapped_tax === '') {
1829 continue;
1830 }
1831 if (isset($theme_schema[$field_key]) && isset($theme_schema[$field_key]['type']) &&
1832 $theme_schema[$field_key]['type'] === 'taxonomy' && isset($theme_schema[$field_key]['name'])) {
1833 $default_tax = $theme_schema[$field_key]['name'];
1834 if ($default_tax !== $mapped_tax) {
1835 $taxonomy_overrides[$default_tax] = $mapped_tax;
1836 }
1837 }
1838 }
1839 }
1840
1841 // Theme-agnostic override. The local hardcoded schema above is the
1842 // WPResidence mapping, so its default-taxonomy slugs do NOT match the
1843 // taxonomies the server built when a different theme (e.g. Houzez) was
1844 // used — the slug-based overrides then silently miss. For the core
1845 // fields that also arrive with a top-level copy, locate the field's
1846 // value inside the server-built taxonomies and redirect THAT taxonomy to
1847 // the user's mapped one. Result: "map field -> Category" moves the value
1848 // out of the server default and into the chosen taxonomy, on any theme.
1849 $core_field_value_sources = array(
1850 'StandardStatus' => 'StandardStatus',
1851 'PropertyType' => 'adr_type',
1852 'City' => 'adr_city',
1853 'CountyOrParish' => 'adr_county',
1854 );
1855 if (isset($options['mls-fields-map-taxonomy']) && is_array($options['mls-fields-map-taxonomy'])) {
1856 foreach ($core_field_value_sources as $reso_field => $top_level_key) {
1857 $mapped_tax = isset($options['mls-fields-map-taxonomy'][$reso_field]) ? $options['mls-fields-map-taxonomy'][$reso_field] : '';
1858 if ($mapped_tax === '' || !isset($property[$top_level_key]) || '' === $property[$top_level_key]) {
1859 continue;
1860 }
1861 $field_value = trim((string) $property[$top_level_key]);
1862 foreach ($property['taxonomies'] as $server_tax => $server_terms) {
1863 if ($server_tax === $mapped_tax) {
1864 continue;
1865 }
1866 $term_list = is_array($server_terms) ? $server_terms : array($server_terms);
1867 $term_list = array_map('trim', array_map('strval', $term_list));
1868 if (in_array($field_value, $term_list, true)) {
1869 $taxonomy_overrides[$server_tax] = $mapped_tax;
1870 }
1871 }
1872 }
1873 }
1874
1875 // Disable term counting temporarily (major memory saver)
1876 wp_defer_term_counting(true);
1877
1878 remove_filter('get_term_metadata', 'lazyload_term_meta', 10);
1879 wp_cache_delete('get_ancestors', 'taxonomy');
1880
1881 // Clear existing taxonomies
1882 $this->mlsimportSaasClearPropertyForTaxonomy($propertyId, $property['taxonomies']);
1883
1884 // 4. PROCESS TAXONOMIES IN CHUNKS
1885 $taxChunks = array_chunk($property['taxonomies'], 5, true);
1886 foreach ($taxChunks as $taxChunk) {
1887 foreach ($taxChunk as $taxonomy => $term) {
1888 if (isset($taxonomy_overrides[$taxonomy])) {
1889 $taxonomy = $taxonomy_overrides[$taxonomy];
1890 }
1891 wp_cache_delete("{$taxonomy}_term_counts", 'counts');
1892 $this->mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $term);
1893 $propertyHistory[] = 'Updated Taxonomy ' . $taxonomy . ' with terms ' . wp_json_encode($term);
1894
1895 // Memory cleanup after each taxonomy
1896 wp_cache_delete('term_meta', 'terms');
1897 wp_cache_delete($taxonomy, 'terms');
1898 }
1899
1900 // 5. FORCE GC AFTER EACH CHUNK
1901 gc_collect_cycles();
1902 }
1903
1904 // Restore term filter and clean up
1905 add_filter('get_term_metadata', 'lazyload_term_meta', 10, 2);
1906 delete_option('category_children');
1907
1908 // Re-enable term counting
1909 wp_defer_term_counting(false);
1910
1911 $memAfterTax = memory_get_usage(true);
1912 // " MB, Total Diff: " . round(($memAfterTax - $memBeforeTax) / 1048576, 2) . " MB");
1913 }
1914
1915 // 6. FLUSH SPECIFIC CACHES INSTEAD OF ALL
1916 // More targeted than wp_cache_flush()
1917 wp_cache_delete('terms', 'terms');
1918 wp_cache_delete('term_meta', 'terms');
1919 wp_cache_delete("post_meta_{$propertyId}", 'post_meta');
1920 wp_cache_delete($propertyId, 'posts');
1921
1922 // Prepare meta data
1923 $property = $this->mlsimportSaasPrepareMetaForProperty($property);
1924
1925 // 7. BATCH META UPDATES
1926 if (isset($property['meta']) && is_array($property['meta'])) {
1927 $memBeforeMeta = memory_get_usage(true);
1928 $metaCount = count($property['meta']);
1929
1930 // Use direct SQL for batch meta updates if many fields
1931 if ($metaCount > 0 && method_exists($wpdb, 'prepare')) {
1932 $meta_values = [];
1933 foreach ($property['meta'] as $metaName => $metaValue) {
1934 if (is_array($metaValue)) {
1935 $metaValue = implode(', ', array_map('trim', $metaValue));
1936 } else {
1937 $metaValue = preg_replace('/\s*,\s*/', ', ', trim($metaValue));
1938 }
1939
1940 // Build history separately
1941 $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1942
1943 // First delete existing
1944 $wpdb->delete(
1945 $wpdb->postmeta,
1946 ['post_id' => $propertyId, 'meta_key' => $metaName],
1947 ['%d', '%s']
1948 );
1949
1950 // Collect for batch insert
1951 $meta_values[] = $wpdb->prepare(
1952 "(%d, %s, %s)",
1953 $propertyId,
1954 $metaName,
1955 $metaValue
1956 );
1957 }
1958
1959 // Batch insert all meta at once
1960 if (!empty($meta_values)) {
1961 $wpdb->query("INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES " .
1962 implode(", ", $meta_values));
1963 }
1964 } else {
1965 // Dead code - left intentianaly
1966 // Standard approach for fewer meta fields
1967 foreach ($property['meta'] as $metaName => $metaValue) {
1968 if (is_array($metaValue)) {
1969 $metaValue = implode(', ', array_map('trim', $metaValue));
1970 } else {
1971 $metaValue = preg_replace('/\s*,\s*/', ', ', trim($metaValue));
1972 }
1973 update_post_meta($propertyId, $metaName, $metaValue);
1974 $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1975 }
1976 }
1977
1978 $memAfterMeta = memory_get_usage(true);
1979 // " MB, Diff: " . round(($memAfterMeta - $memBeforeMeta) / 1048576, 2) . " MB");
1980 }
1981
1982 // Extra meta processing
1983 $extraMetaResult = $mlsimport->admin->env_data->mlsimportSaasSetExtraMeta($propertyId, $property);
1984 if (isset($extraMetaResult['property_history'])) {
1985 $propertyHistory = array_merge($propertyHistory, (array)$extraMetaResult['property_history']);
1986 }
1987
1988 // 8. PROCESS MEDIA IN CHUNKS
1989 $memBeforeMedia = memory_get_usage(true);
1990
1991
1992 if (isset($property['Media']) && is_array($property['Media'])) {
1993 $media_attachments=array();
1994
1995 $mediaCount = count($property['Media']);
1996
1997 // Detect if media has changed for existing properties
1998 $shouldRefreshMedia = false;
1999 if ($isInsert === 'no') {
2000 $shouldRefreshMedia = $this->hasMediaChanged($propertyId, $property['Media']);
2001 if ($shouldRefreshMedia) {
2002 $this->writeImportLogs('Media changed for property ' . $propertyId . ', refreshing ' . $mediaCount . ' images', $tipImport);
2003 $this->deleteExistingMlsAttachments($propertyId);
2004 } else {
2005 $this->writeImportLogs('Media unchanged for property ' . $propertyId . ', skipping image refresh', $tipImport);
2006 }
2007 }
2008
2009 // Sort media by Order field if it exists
2010 if (isset($property['Media'][0]['Order'])) {
2011 $order = array_column($property['Media'], 'Order');
2012 array_multisort($order, SORT_ASC, $property['Media']);
2013 }
2014
2015 // Process in chunks of 5
2016 $mediaChunks = array_chunk($property['Media'], 5,true);
2017 $mediaHistoryParts = [];
2018
2019 // Clear original array to free memory
2020 $originalMedia = $property['Media'];
2021
2022 // Find featured image in single loop
2023 $featuredImageKey = null;
2024 $orderOneKey = null;
2025
2026 // First priority: Look for PreferredPhotoYN = 1
2027 foreach ($property['Media'] as $key => $mediaItem) {
2028 // Priority 1: PreferredPhotoYN = 1 (immediate selection)
2029 if (isset($mediaItem['PreferredPhotoYN']) && $mediaItem['PreferredPhotoYN'] == 1) {
2030 $featuredImageKey = $key;
2031 break;
2032 }
2033
2034
2035 // Priority 2: Store Order = 1 key for potential use
2036 if ($orderOneKey === null && isset($mediaItem['Order']) && $mediaItem['Order'] == 1) {
2037 $orderOneKey = $key;
2038 }
2039
2040 }
2041
2042 if ($featuredImageKey === null && $orderOneKey !== null) {
2043 $featuredImageKey = $orderOneKey;
2044 }
2045
2046 // Use Order = 1 image if no preferred image was found
2047 if ($featuredImageKey === null && $orderOneKey !== null) {
2048 $featuredImageKey = $orderOneKey;
2049 }
2050
2051 // Priority 3: Use first image if nothing else found
2052 if ($featuredImageKey === null && !empty($property['Media'])) {
2053 $featuredImageKey = 0;
2054 }
2055
2056
2057 unset($property['Media']);
2058
2059 if ($isInsert !== 'no' || $shouldRefreshMedia) {
2060 delete_post_meta($propertyId, 'fave_property_images');
2061 delete_post_meta($propertyId, 'REAL_HOMES_property_images');
2062 delete_post_meta($propertyId, 'wpestate_property_gallery');
2063 }
2064
2065
2066 foreach ($mediaChunks as $index => $mediaChunk) {
2067 $media_attachments = $this->mlsimportSassAttachMediaToPost($propertyId, $mediaChunk, $isInsert,$media_attachments,$featuredImageKey, $shouldRefreshMedia);
2068 // $mediaHistoryParts[] = $chunkHistory;
2069
2070 // Free memory
2071 unset($mediaChunk);
2072 //unset($chunkHistory);
2073 gc_collect_cycles();
2074
2075 // Incremental progress report
2076 }
2077
2078
2079 // Only rewrite the gallery when we actually (re)built the attachment list
2080 // (insert or media refresh). On the unchanged-media path $media_attachments
2081 // is empty, and overwriting would wipe the existing gallery.
2082 if ($isInsert !== 'no' || $shouldRefreshMedia) {
2083 $mlsimport->admin->env_data->enviroment_image_save_gallery($propertyId, $media_attachments);
2084
2085 // Guarantee a featured image. When the feed has no preferred photo
2086 // (PreferredPhotoYN) and no Order == 1 image — as with AMPRE/PropTx,
2087 // whose PreferredPhotoYN is empty and Order is not 1-based — fall back
2088 // to the first attached image so the property always has a thumbnail.
2089 if ( ! empty( $media_attachments ) && ! has_post_thumbnail( $propertyId ) ) {
2090 set_post_thumbnail( $propertyId, reset( $media_attachments ) );
2091 }
2092 }
2093
2094 // Combine all chunks
2095 // $mediaHistory = implode('</br>', $mediaHistoryParts);
2096 // $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
2097
2098 // Clean up
2099 unset($mediaChunks);
2100 unset($mediaHistoryParts);
2101 unset($mediaHistory);
2102 unset($originalMedia);
2103 } else {
2104 $mediaHistory = $this->mlsimportSassAttachMediaToPost($propertyId, $property['Media'] ?? [], $isInsert,$featuredImageKey);
2105 $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
2106 }
2107
2108
2109 $memAfterMedia = memory_get_usage(true);
2110 // " MB, Diff: " . round(($memAfterMedia - $memBeforeMedia) / 1048576, 2) . " MB");
2111
2112 // Update title
2113 $newTitle = $this->mlsimportSaasUpdatePropertyTitle($propertyId, $itemIdArray['item_id'], $property);
2114 $propertyHistory[] = 'Updated title to ' . $newTitle . '</br>';
2115
2116 // Correlation update
2117 $mlsimport->admin->env_data->correlationUpdateAfter($isInsert, $propertyId, [], $newAgent);
2118
2119 // 9. COMMIT TRANSACTION
2120 if (method_exists($wpdb, 'query')) {
2121 $wpdb->query('COMMIT');
2122 $wpdb->query('SET autocommit = 1');
2123 }
2124
2125 // Save property history - using direct SQL if history is large
2126 if (!empty($propertyHistory)) {
2127 if (intval(get_option('mlsimport-disable-history', 1)) === 1) {
2128 $propertyHistory[] = '---------------------------------------------------------------</br>';
2129 $propertyHistory = implode('</br>', $propertyHistory);
2130
2131 // 10. USE DIRECT SQL FOR LARGE HISTORY
2132 if (strlen($propertyHistory) > 10000 && method_exists($wpdb, 'update')) {
2133 $wpdb->update(
2134 $wpdb->postmeta,
2135 ['meta_value' => $propertyHistory],
2136 ['post_id' => $propertyId, 'meta_key' => 'mlsimport_property_history'],
2137 ['%s'],
2138 ['%d', '%s']
2139 );
2140 } else {
2141 update_post_meta($propertyId, 'mlsimport_property_history', $propertyHistory);
2142 }
2143 }
2144 }
2145
2146 // 11. RESTORE ACTIONS
2147 if (!empty($suspended_actions)) {
2148 foreach ($suspended_actions as $action => $true) {
2149 add_action($action, '_wp_action_exists_' . $action);
2150 remove_action($action, '_wp_action_exists_' . $action);
2151 }
2152 }
2153
2154 // 12. FINAL CLEANUP
2155 $property = null;
2156 $propertyHistory = null;
2157 wp_cache_flush();
2158 gc_collect_cycles();
2159
2160 // Final memory stats
2161 $memEnd = memory_get_usage(true);
2162
2163 return $newTitle;
2164 }
2165
2166
2167 /**
2168 * Check if incoming MLS media differs from existing MLS-imported attachments.
2169 *
2170 * Compares incoming MediaURL values against the GUIDs of existing attachments
2171 * that have the is_mlsimport meta flag. Uses ID-only queries for memory efficiency.
2172 *
2173 * @param int $propertyId The property post ID.
2174 * @param array $incomingMedia Array of media items, each with a 'MediaURL' key.
2175 * @return bool True if images need refresh, false if unchanged.
2176 */
2177 private function hasMediaChanged($propertyId, $incomingMedia) {
2178 $existing = get_posts([
2179 'post_type' => 'attachment',
2180 'post_parent' => $propertyId,
2181 'post_status' => 'inherit',
2182 'meta_key' => 'is_mlsimport',
2183 'meta_value' => 1,
2184 'fields' => 'ids',
2185 'numberposts' => -1,
2186 ]);
2187
2188 $existingUrls = array_map(function ($id) {
2189 return get_post_field('guid', $id);
2190 }, $existing);
2191
2192 $incomingUrls = array_filter(array_column($incomingMedia, 'MediaURL'));
2193
2194 sort($existingUrls);
2195 sort($incomingUrls);
2196
2197 return $existingUrls !== $incomingUrls;
2198 }
2199
2200
2201 /**
2202 * Delete all MLS-imported attachments for a property.
2203 *
2204 * Only deletes attachments that have the is_mlsimport post meta set to 1.
2205 * Manually uploaded attachments are preserved.
2206 *
2207 * @param int $propertyId The property post ID.
2208 */
2209 private function deleteExistingMlsAttachments($propertyId) {
2210 $mlsAttachments = get_posts([
2211 'post_type' => 'attachment',
2212 'post_parent' => $propertyId,
2213 'post_status' => 'inherit',
2214 'meta_key' => 'is_mlsimport',
2215 'meta_value' => 1,
2216 'fields' => 'ids',
2217 'numberposts' => -1,
2218 ]);
2219
2220 foreach ($mlsAttachments as $attachId) {
2221 wp_delete_post($attachId, true);
2222 }
2223
2224 // The old featured image is one of the attachments we just deleted, so the
2225 // stale _thumbnail_id now points at nothing. Clear it — otherwise
2226 // has_post_thumbnail() stays true and the fresh featured image is never set.
2227 delete_post_thumbnail($propertyId);
2228 }
2229
2230
2231
2232 }
2233