PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 6.3.6
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v6.3.6
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.6, at includes/ThemeImport.php

2,216 lines 73.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 if (is_wp_error($attachId)) {
955 } else {
956 $mediaHistory[] = 'Media - Added ' . $file . ' as attachment ' . $attachId;
957 $media_attachments[]=$attachId;
958
959
960 $mlsimport->admin->env_data->enviroment_image_save($propertyId, $attachId);
961 update_post_meta($attachId, 'is_mlsimport', 1);
962
963 if ($key===$featuredImageKey){
964
965
966 set_post_thumbnail($propertyId, $attachId);
967
968 } else {
969 }
970 }
971 } else {
972 }
973 }
974 } else {
975 $mediaHistory[] = 'Media data is blank - there are no images';
976 }
977
978 remove_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']);
979
980 return $media_attachments;
981 //return implode('</br>', $mediaHistory);
982 }
983
984
985 /**
986 * Unset image sizes
987 *
988 * @param array $sizes The sizes to unset.
989 * @return array The modified sizes array.
990 */
991 public function wpcUnsetImageSizes($sizes) {
992 return [];
993 }
994
995
996
997
998
999
1000
1001 /**
1002 * Return user option
1003 *
1004 * @param int $selected The selected user ID.
1005 * @return string The HTML option elements for users.
1006 */
1007 public function mlsimportSaasThemeImportSelectUser($selected) {
1008 $userOptions = '';
1009 $blogusers = get_users(['blog_id' => 1, 'orderby' => 'nicename']);
1010 foreach ($blogusers as $user) {
1011 $userOptions .= '<option value="' . esc_attr($user->ID) . '"';
1012 if ($user->ID == $selected) {
1013 $userOptions .= ' selected="selected"';
1014 }
1015 $userOptions .= '>' . esc_html($user->user_login) . '</option>';
1016 }
1017 return $userOptions;
1018 }
1019
1020
1021
1022
1023
1024
1025
1026 /**
1027 * Return agent option
1028 *
1029 * @param int $selected The selected agent ID.
1030 * @return string The HTML option elements for agents.
1031 */
1032 public function mlsimportSaasThemeImportSelectAgent($selected) {
1033 global $mlsimport;
1034 $args = [
1035 'post_type' => $mlsimport->admin->env_data->get_agent_post_type(),
1036 'post_status' => 'publish',
1037 'posts_per_page' => 150,
1038 ];
1039
1040 $agentSelection = new WP_Query($args);
1041 $agentOptions = '<option value=""></option>';
1042
1043 while ($agentSelection->have_posts()) {
1044 $agentSelection->the_post();
1045 $agentId = get_the_ID();
1046
1047 $agentOptions .= '<option value="' . esc_attr($agentId) . '"';
1048 if ($agentId == $selected) {
1049 $agentOptions .= ' selected="selected"';
1050 }
1051 $agentOptions .= '>' . esc_html(get_the_title()) . '</option>';
1052 }
1053 wp_reset_postdata();
1054
1055 return $agentOptions;
1056 }
1057
1058
1059
1060
1061
1062
1063
1064
1065 /**
1066 * Delete property
1067 *
1068 * @param int $deleteId The ID of the property to delete.
1069 * @param string $ListingKey The listing key of the property.
1070 */
1071 public function deleteProperty($deleteId, $ListingKey) {
1072 if ($deleteId > 0) {
1073 mlsimport_record_activity( 'deleted', $deleteId, get_post_meta($deleteId,'ListingKey',true), intval(get_post_meta($deleteId,'MLSimport_item_inserted',true)), 'import' );
1074 $args = [
1075 'numberposts' => -1,
1076 'post_type' => 'attachment',
1077 'post_parent' => $deleteId,
1078 'post_status' => null,
1079 'orderby' => 'menu_order',
1080 'order' => 'ASC',
1081 ];
1082 $postAttachments = get_posts($args);
1083
1084 foreach ($postAttachments as $attachment) {
1085 wp_delete_post($attachment->ID);
1086 }
1087
1088 wp_delete_post($deleteId);
1089 mlsimport_telemetry_bump( 'deleted' );
1090 $logEntry = 'Property with id ' . $deleteId . ' and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
1091 $this->writeImportLogs($logEntry, 'delete');
1092 }
1093 }
1094
1095
1096
1097
1098 /**
1099 * Return array with title items
1100 *
1101 * @param string $string The input string.
1102 * @param string $start The start delimiter.
1103 * @param string $end The end delimiter.
1104 * @param bool $includeDelimiters Whether to include the delimiters in the result.
1105 * @param int $offset The offset to start searching from.
1106 * @return array The array of strings found between the delimiters.
1107 */
1108 public function strBetweenAll(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): array {
1109 $strings = [];
1110 $length = strlen($string);
1111
1112 while ($offset < $length) {
1113 $found = $this->strBetween($string, $start, $end, $includeDelimiters, $offset);
1114 if ($found === null) {
1115 break;
1116 }
1117
1118 $strings[] = $found;
1119 $offset += strlen($includeDelimiters ? $found : $start . $found . $end); // move offset to the end of the newfound string
1120 }
1121
1122 return $strings;
1123 }
1124
1125 /**
1126 * Find string between delimiters
1127 *
1128 * @param string $string The input string.
1129 * @param string $start The start delimiter.
1130 * @param string $end The end delimiter.
1131 * @param bool $includeDelimiters Whether to include the delimiters in the result.
1132 * @param int $offset The offset to start searching from.
1133 * @return string|null The string found between the delimiters, or null if not found.
1134 */
1135 public function strBetween(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): ?string {
1136 if ($string === '' || $start === '' || $end === '') {
1137 return null;
1138 }
1139
1140 $startLength = strlen($start);
1141 $endLength = strlen($end);
1142
1143 $startPos = strpos($string, $start, $offset);
1144 if ($startPos === false) {
1145 return null;
1146 }
1147
1148 $endPos = strpos($string, $end, $startPos + $startLength);
1149 if ($endPos === false) {
1150 return null;
1151 }
1152
1153 $length = $endPos - $startPos + ($includeDelimiters ? $endLength : -$startLength);
1154 if (!$length) {
1155 return '';
1156 }
1157
1158 $offset = $startPos + ($includeDelimiters ? 0 : $startLength);
1159
1160 return substr($string, $offset, $length);
1161 }
1162
1163
1164
1165
1166
1167 /**
1168 * Delete property via SQL
1169 *
1170 * @param int $deleteId The ID of the property to delete.
1171 * @param string $ListingKey The listing key of the property.
1172 */
1173 public function mlsimportSaasDeletePropertyViaMysql($deleteId, $ListingKey) {
1174 global $mlsimport;
1175
1176 $postType = get_post_type($deleteId);
1177 $propertyPostType = '';
1178 if (isset($mlsimport->admin->env_data) && method_exists($mlsimport->admin->env_data, 'get_property_post_type')) {
1179 $propertyPostType = $mlsimport->admin->env_data->get_property_post_type();
1180 }
1181
1182 if ($postType === $propertyPostType || in_array($postType, ['estate_property', 'property'])) {
1183 // Delete attachments using WordPress functions so the files are removed as well
1184 $attachments = get_posts([
1185 'numberposts' => -1,
1186 'post_type' => 'attachment',
1187 'post_parent' => $deleteId,
1188 'post_status' => null,
1189 'fields' => 'ids',
1190 ]);
1191
1192 foreach ($attachments as $attachmentId) {
1193 wp_delete_attachment($attachmentId, true);
1194 }
1195
1196 $termObjList = get_the_terms($deleteId, 'property_status');
1197 $deleteIdStatus = join(', ', wp_list_pluck($termObjList, 'name'));
1198
1199 $ListingKey = get_post_meta($deleteId, 'ListingKey', true);
1200 if ('' === $ListingKey) { // manually added listing
1201 $logEntry = 'User added listing with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' NOT DELETED' . PHP_EOL;
1202 $this->writeImportLogs($logEntry, 'delete');
1203 return;
1204 }
1205
1206 mlsimport_record_activity( 'deleted', $deleteId, $ListingKey, intval(get_post_meta($deleteId,'MLSimport_item_inserted',true)), 'reconciliation' );
1207
1208 global $wpdb;
1209 $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->postmeta WHERE `post_id` = %d", $deleteId));
1210 $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE `post_parent` = %d OR `ID` = %d", $deleteId, $deleteId));
1211 mlsimport_telemetry_bump( 'deleted' );
1212
1213 $logEntry = 'MYSQL DELETE -> Property with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
1214 $this->writeImportLogs($logEntry, 'delete');
1215 }
1216 }
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226 /**
1227 * Prepare to import per item
1228 *
1229 * @param array $property The property data.
1230 * @param array $itemIdArray The item ID array.
1231 * @param string $tipImport The import type.
1232 * @param array $mlsimportItemOptionData The item option data.
1233 */
1234 public function mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, $tipImport, $mlsimportItemOptionData) {
1235 // Pre-execution memory optimization
1236 wp_cache_flush();
1237 gc_collect_cycles();
1238
1239 // Temporarily disable WordPress hooks that might add to memory usage
1240 global $wp_filter;
1241 $saved_filters = array();
1242 if (isset($wp_filter['transition_post_status'])) {
1243 $saved_filters['transition_post_status'] = $wp_filter['transition_post_status'];
1244 unset($wp_filter['transition_post_status']);
1245 }
1246 if (isset($wp_filter['save_post'])) {
1247 $saved_filters['save_post'] = $wp_filter['save_post'];
1248 $wp_filter['save_post'] = new WP_Hook();
1249 }
1250 set_time_limit(0);
1251 global $mlsimport;
1252
1253 // Log initial memory
1254 $memStart = memory_get_usage(true);
1255 $memStartMB = round($memStart / 1048576, 2);
1256
1257 $mlsImportItemStatus = $mlsimportItemOptionData['mlsimport_item_standardstatus'];
1258 $newAuthor = $mlsimportItemOptionData['mlsimport_item_property_user'];
1259 $newAgent = $mlsimportItemOptionData['mlsimport_item_agent'];
1260 $propertyStatus = $mlsimportItemOptionData['mlsimport_item_property_status'];
1261
1262 if (is_array($mlsImportItemStatus)) {
1263 $mlsImportItemStatus = array_map('mlsimport_normalize_status_enum', $mlsImportItemStatus);
1264 }
1265
1266 if (!isset($property['ListingKey']) || empty($property['ListingKey'])) {
1267 $this->writeImportLogs('ERROR: No Listing Key ' . PHP_EOL, $tipImport);
1268 return;
1269 }
1270
1271 ob_start();
1272
1273 $ListingKey = $property['ListingKey'];
1274 $listingPostType = $mlsimport->admin->env_data->get_property_post_type();
1275
1276 // Memory before property ID lookup
1277 $memBeforeRetrieve = memory_get_usage(true);
1278
1279 $propertyId = intval($this->mlsimportSaasRetrievePropertyById($ListingKey, $listingPostType));
1280
1281 // Memory after property ID lookup
1282 $memAfterRetrieve = memory_get_usage(true);
1283
1284 $status = isset($property['StandardStatus']) ? mlsimport_normalize_status_enum($property['StandardStatus']) : mlsimport_normalize_status_enum($property['extra_meta']['MlsStatus']);
1285
1286 $this->writeImportLogs('FIxing: on inserting ' .$status.'-->'.json_encode($mlsImportItemStatus). PHP_EOL, $tipImport);
1287
1288 $isInsert = $this->shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport);
1289
1290 $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;
1291 $this->writeImportLogs($log, $tipImport);
1292
1293 $propertyHistory = [];
1294 $content = $property['content'] ?? '';
1295 $submitTitle = $ListingKey;
1296
1297 // Memory before insert/update
1298 $memBeforeInsert = memory_get_usage(true);
1299
1300 $activityAction = '';
1301
1302 // Incoming MLS modification time (unix). The hourly delta sync uses a
1303 // rolling 2-hour overlap window, so the SAME listing is returned on
1304 // several consecutive runs. We compare this against the last value we
1305 // recorded (mlsimport_synced_mod) to count a listing as "edited" only
1306 // when it actually changed since our last import — not on every re-touch.
1307 $incomingMod = isset($property['extra_meta']['ModificationTimestamp'])
1308 ? strtotime((string) $property['extra_meta']['ModificationTimestamp'])
1309 : 0;
1310 if (false === $incomingMod) {
1311 $incomingMod = 0;
1312 }
1313
1314 if ($isInsert === 'yes') {
1315 $post = [
1316 'post_title' => $submitTitle,
1317 'post_content' => $content,
1318 'post_status' => $propertyStatus,
1319 'post_type' => $listingPostType,
1320 'post_author' => $newAuthor,
1321 ];
1322
1323 $propertyId = wp_insert_post($post);
1324
1325 if (is_wp_error($propertyId)) {
1326 $this->writeImportLogs('ERROR: on inserting ' . PHP_EOL, $tipImport);
1327 } else {
1328 update_post_meta($propertyId, 'ListingKey', $ListingKey);
1329 update_post_meta($propertyId, 'MLSimport_item_inserted', $itemIdArray['item_id'],);
1330 $activityAction = 'added';
1331 update_post_meta($propertyId, 'mlsimport_synced_mod', $incomingMod);
1332 $propertyHistory[] = date('F j, Y, g:i a') . ': We Inserted the property with Default title : ' . $submitTitle . ' and received id:' . $propertyId;
1333 mlsimport_telemetry_bump( 'imported' );
1334 }
1335
1336 clean_post_cache($propertyId);
1337
1338 } elseif ($propertyId !== 0) {
1339
1340
1341 // Memory before checking existing property
1342 $memBeforeCheck = memory_get_usage(true);
1343
1344 $keep = $this->shouldKeepExistingListing($status, $mlsImportItemStatus);
1345
1346
1347 if(!$keep){
1348 $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;
1349
1350 // Memory before delete
1351 $memBeforeDelete = memory_get_usage(true);
1352
1353 $this->deleteProperty($propertyId, $ListingKey);
1354
1355 // Memory after delete
1356 $memAfterDelete = memory_get_usage(true);
1357
1358 $this->writeImportLogs($log, $tipImport);
1359 } else {
1360 // Memory before updating
1361 $memBeforeUpdate = memory_get_usage(true);
1362
1363 $propertyHistory = $this->updateExistingProperty($propertyId, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, $propertyHistory, $tipImport, $ListingKey);
1364
1365 // Count as "edited" only when the listing actually changed since our
1366 // last import. A missing incoming timestamp (0) means we can't tell,
1367 // so fall back to recording the edit.
1368 $storedMod = (int) get_post_meta($propertyId, 'mlsimport_synced_mod', true);
1369 if (0 === $incomingMod || $incomingMod > $storedMod) {
1370 $activityAction = 'edited';
1371 if ($incomingMod > 0) {
1372 update_post_meta($propertyId, 'mlsimport_synced_mod', $incomingMod);
1373 }
1374 }
1375
1376 // Memory after updating
1377 $memAfterUpdate = memory_get_usage(true);
1378 }
1379 }
1380
1381 // Memory after insert/update
1382 $memAfterInsert = memory_get_usage(true);
1383
1384 if ($propertyId === 0) {
1385 $this->writeImportLogs('ERROR property id is 0' . PHP_EOL, $tipImport);
1386 return;
1387 }
1388
1389 // Memory before processing details
1390 $memBeforeDetails = memory_get_usage(true);
1391
1392 $newTitle = $this->processPropertyDetails($property, $propertyId, $tipImport, $propertyHistory, $newAgent, $itemIdArray,$isInsert);
1393
1394 if ( $activityAction !== '' ) {
1395 mlsimport_record_activity( $activityAction, $propertyId, $ListingKey, isset($itemIdArray['item_id']) ? intval($itemIdArray['item_id']) : 0, $tipImport );
1396 }
1397
1398 // Memory after processing details
1399 $memAfterDetails = memory_get_usage(true);
1400
1401 $log = PHP_EOL . 'Ending on Property ' . $propertyId . ', ListingKey: ' . $ListingKey . ' , is insert? ' . $isInsert . ' with new title: ' . $newTitle . ' ' . PHP_EOL;
1402 $this->writeImportLogs($log, $tipImport);
1403
1404 clean_post_cache($propertyId);
1405
1406 // More aggressive memory cleanup
1407 // First clear specific large arrays in property data
1408 if (isset($property['Media']) && is_array($property['Media'])) {
1409 foreach ($property['Media'] as $key => $media) {
1410 unset($property['Media'][$key]);
1411 }
1412 }
1413 if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1414 foreach ($property['extra_meta'] as $key => $value) {
1415 unset($property['extra_meta'][$key]);
1416 }
1417 }
1418 if (isset($property['meta']) && is_array($property['meta'])) {
1419 foreach ($property['meta'] as $key => $value) {
1420 unset($property['meta'][$key]);
1421 }
1422 }
1423 if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1424 foreach ($property['taxonomies'] as $key => $value) {
1425 unset($property['taxonomies'][$key]);
1426 }
1427 }
1428
1429 // Then unset the main arrays
1430 unset($property['Media']);
1431 unset($property['extra_meta']);
1432 unset($property['meta']);
1433 unset($property['taxonomies']);
1434 unset($property);
1435
1436 // Clear any post caches that might have been created
1437 clean_post_cache($propertyId);
1438
1439 // Clear other variables that hold large data
1440 unset($log);
1441 unset($propertyHistory);
1442 $GLOBALS['wpdb']->queries = array();
1443
1444 // Clear WordPress specific caches
1445 wp_cache_delete('get_term_meta', 'terms');
1446 wp_cache_delete('terms', 'terms');
1447 wp_cache_delete('term_meta', 'terms');
1448 wp_cache_delete('get_terms', 'terms');
1449
1450 // Clear post related caches
1451 wp_cache_delete('post_meta_' . $propertyId, 'post_meta');
1452 wp_cache_delete($propertyId, 'posts');
1453
1454 // Force multiple garbage collection cycles
1455 gc_collect_cycles();
1456 gc_collect_cycles();
1457
1458 // Close and discard any output buffer content
1459 ob_end_clean();
1460
1461 // Try to trigger PHP's internal memory cleanup
1462 $dummy = str_repeat('x', 1024 * 1024);
1463 unset($dummy);
1464
1465 // Final memory usage
1466 $memEnd = memory_get_usage(true);
1467 $memEndMB = round($memEnd / 1048576, 2);
1468 $memDiff = round(($memEnd - $memStart) / 1048576, 2);
1469
1470 // If we see a significant memory increase, log a warning
1471 if ($memDiff > 5) {
1472 }
1473
1474 // Restore WordPress hooks
1475 global $wp_filter;
1476 if (!empty($saved_filters)) {
1477 foreach ($saved_filters as $hook => $filter) {
1478 $wp_filter[$hook] = $filter;
1479 }
1480 }
1481 }
1482
1483
1484
1485
1486
1487
1488 /**
1489 * Check if the property should be inserted
1490 *
1491 * @param int $propertyId The property ID.
1492 * @param string $status The property status.
1493 * @param array $mlsImportItemStatus The MLS import item status.
1494 * @param string $tipImport The import type.
1495 * @return string 'yes' or 'no' indicating if the property should be inserted.
1496 */
1497 private function shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport): string{
1498 $this->writeImportLogs(
1499 "Checking: on inserting {$propertyId}={$status} vs " .
1500 json_encode($mlsImportItemStatus) . " -- {$tipImport}" . PHP_EOL,
1501 $tipImport
1502 );
1503
1504
1505 if ($propertyId !== 0 || !is_array($mlsImportItemStatus)) {
1506 return 'no';
1507
1508 }
1509
1510 $activeStatuses = [
1511 'active',
1512 'active under contract',
1513 'active with contract',
1514 'activewithcontract',
1515 'status',
1516 'activeundercontract',
1517 'comingsoon',
1518 'coming soon',
1519 'pending'
1520 ];
1521 if(is_array($mlsImportItemStatus)){
1522 if (!in_array(strtolower($status), $mlsImportItemStatus, true)) {
1523 return 'no';
1524 }
1525
1526 if ($tipImport === 'cron' && !in_array($status, $mlsImportItemStatus, true)) {
1527 return 'no';
1528 }
1529
1530 }else{
1531 if(!in_array($status, $activeStatuses, true) ){
1532 return 'no';
1533 }
1534 }
1535
1536 return 'yes';
1537 }
1538
1539
1540 /**
1541 * Check for property status against MLS item delete status to see if we keep or delete the listing.
1542 * @param int $property_id
1543 * @param string|array $mlsImportItemStatus
1544 * @return bool True to keep, false to delete
1545 */
1546 public function check_if_delete_when_status($property_id, $mlsImportItemStatus, $mlsImportItemStatusDelete = null, $mlsImportItemStatusProtect = null) {
1547
1548 // Get post_status based on post type/taxonomy. The status taxonomy is
1549 // resolved from the user's StandardStatus field mapping (theme default as
1550 // fallback): a task can map status onto a different taxonomy than the
1551 // hardcoded default, and reading the wrong one returns an empty status that
1552 // wrongly deletes live listings during reconciliation.
1553 $post_status = '';
1554 $mlsimport_status_tax_map = ( $mlsimport_fields_opt = get_option('mlsimport_admin_fields_select') ) && isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
1555 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
1556 if (post_type_exists('estate_property')) {
1557 $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_status'));
1558 if (!empty($terms) && is_array($terms)) {
1559 $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1560 }
1561 } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1562 $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_label'));
1563 if (!empty($terms) && is_array($terms)) {
1564 $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1565 }
1566 } else {
1567 $post_status = mlsimport_normalize_status_enum(get_post_meta($property_id, 'inspiry_property_label', true));
1568 }
1569
1570 // Protected statuses: keep if property status matches
1571 if (!empty($mlsImportItemStatusProtect)) {
1572 $mlsImportItemStatusProtect = is_array($mlsImportItemStatusProtect)
1573 ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatusProtect)
1574 : array(mlsimport_normalize_status_enum($mlsImportItemStatusProtect));
1575 if (in_array($post_status, $mlsImportItemStatusProtect, true)) {
1576 return true;
1577 }
1578 }
1579
1580 // Default: delete if not protected
1581 return false;
1582 }
1583
1584
1585
1586
1587 public function check_if_delete_when_status_on_manual_import($property_id, $mlsImportItemStatus) {
1588 // Normalize status arrays/strings to a space-free comparison key so
1589 // Trestle PrettyEnums labels match the raw enum config values.
1590 $mlsImportItemStatus = is_array($mlsImportItemStatus)
1591 ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatus)
1592 : mlsimport_normalize_status_enum($mlsImportItemStatus);
1593
1594 // Get post_status based on post type/taxonomy. The status taxonomy is
1595 // resolved from the user's StandardStatus field mapping (theme default as
1596 // fallback): a task can map status onto a different taxonomy than the
1597 // hardcoded default, and reading the wrong one returns an empty status that
1598 // wrongly deletes live listings during reconciliation.
1599 $post_status = '';
1600 $mlsimport_status_tax_map = ( $mlsimport_fields_opt = get_option('mlsimport_admin_fields_select') ) && isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
1601 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
1602 if (post_type_exists('estate_property')) {
1603 $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_status'));
1604 if (!empty($terms) && is_array($terms)) {
1605 $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1606 }
1607 } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1608 $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_label'));
1609 if (!empty($terms) && is_array($terms)) {
1610 $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1611 }
1612 } else {
1613 $post_status = mlsimport_normalize_status_enum(get_post_meta($property_id, 'inspiry_property_label', true));
1614 }
1615
1616
1617
1618 // Keep if status matches "keep" status
1619 if ((is_array($mlsImportItemStatus) && in_array($post_status, $mlsImportItemStatus, true)) ||
1620 (!is_array($mlsImportItemStatus) && $post_status === $mlsImportItemStatus)) {
1621
1622 return true;
1623 }
1624
1625
1626
1627 // Default: keep
1628 return false;
1629 }
1630
1631
1632 /**
1633 * Decide whether to keep an existing listing the (filtered) feed returned again.
1634 *
1635 * Uses the live MLS status — the SAME basis shouldInsertProperty() uses to
1636 * decide an insert. The old code compared the stored property_status taxonomy
1637 * term instead, which can be empty, remapped, or theme-labeled; when it did not
1638 * equal the RESO status, keep said "delete" while insert said "yes", producing
1639 * an add/delete/add cycle on every sync. See issue #152.
1640 *
1641 * @param string $status Live MLS StandardStatus (lowercased).
1642 * @param array|string $mlsImportItemStatus Task's selected RESO statuses (lowercased).
1643 * @return bool True to keep/update, false to delete.
1644 */
1645 public function shouldKeepExistingListing($status, $mlsImportItemStatus): bool {
1646 return is_array($mlsImportItemStatus) && in_array($status, $mlsImportItemStatus, true);
1647 }
1648
1649
1650
1651
1652
1653 /**
1654 * Check if we should keep or delete the listing when still in MLS.
1655 * true we keep
1656 */
1657 public function check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect = null) {
1658 $post_status = '';
1659
1660 // Resolve the status taxonomy from the user's StandardStatus field
1661 // mapping (theme default as fallback) — see note in check_if_delete_when_status().
1662 $mlsimport_status_tax_map = ( $mlsimport_fields_opt = get_option('mlsimport_admin_fields_select') ) && isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
1663 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
1664
1665 // Check for post status based on post type/taxonomy
1666 if (post_type_exists('estate_property')) {
1667 // WPResidence
1668 $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_status'));
1669 if (!empty($terms) && is_array($terms)) {
1670 $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1671 }
1672 } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1673 // Houzez
1674 $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_label'));
1675 if (!empty($terms) && is_array($terms)) {
1676 $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1677 }
1678 } else {
1679 // RealHomes
1680 $post_status = mlsimport_normalize_status_enum(get_post_meta($property_id, 'inspiry_property_label', true));
1681 }
1682
1683 // Protected statuses: keep if property status matches
1684 if (!empty($mlsimport_item_standardstatusprotect)) {
1685 $mlsimport_item_standardstatusprotect = is_array($mlsimport_item_standardstatusprotect)
1686 ? array_map('mlsimport_normalize_status_enum', $mlsimport_item_standardstatusprotect)
1687 : array(mlsimport_normalize_status_enum($mlsimport_item_standardstatusprotect));
1688 if (in_array($post_status, $mlsimport_item_standardstatusprotect, true)) {
1689 return true;
1690 }
1691 }
1692
1693 // Early return if MLS status empty
1694 if (empty($mlsimport_item_standardstatus)) {
1695 return true; // default: keep if no status set
1696 }
1697
1698 // Normalize standard statuses to a space-free key for comparison
1699 if (is_array($mlsimport_item_standardstatus)) {
1700 $mlsimport_item_standardstatus = array_map('mlsimport_normalize_status_enum', $mlsimport_item_standardstatus);
1701 return in_array($post_status, $mlsimport_item_standardstatus, true);
1702 }
1703 return $post_status === mlsimport_normalize_status_enum($mlsimport_item_standardstatus);
1704 }
1705
1706
1707
1708
1709
1710
1711
1712 /**
1713 * Update existing property
1714 *
1715 * @param int $propertyId The property ID.
1716 * @param string $content The post content.
1717 * @param string $listingPostType The listing post type.
1718 * @param int $newAuthor The new author ID.
1719 * @param string $status The property status.
1720 * @param array $mlsImportItemStatus The MLS import item status.
1721 * @param array $propertyHistory The property history.
1722 * @param string $tipImport The import type.
1723 * @param string $ListingKey The listing key.
1724 * @return array Updated property history.
1725 */
1726 private function updateExistingProperty($propertyId, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, &$propertyHistory, $tipImport, $ListingKey) {
1727
1728
1729 $post = [
1730 'ID' => $propertyId,
1731 'post_content' => $content,
1732 'post_type' => $listingPostType,
1733 'post_author' => $newAuthor,
1734 ];
1735
1736 $log = 'Property with ID ' . $propertyId . ' and with name ' . get_the_title($propertyId) . ' has a status of <strong>' . $status . '</strong> and will be Edited</br>';
1737 $this->writeImportLogs($log, $tipImport);
1738
1739 $propertyId = wp_update_post($post);
1740 if (is_wp_error($propertyId)) {
1741 $this->writeImportLogs('ERROR: on edit ' . PHP_EOL, $tipImport);
1742 } else {
1743 $submitTitle = get_the_title($propertyId);
1744 $propertyHistory[] = gmdate('F j, Y, g:i a') . ': Property with title: ' . $submitTitle . ', id:' . $propertyId . ', ListingKey:' . $ListingKey . ', Status:' . $status . ' will be edited';
1745 mlsimport_telemetry_bump( 'updated' );
1746 }
1747 clean_post_cache( $propertyId );
1748
1749 return $propertyHistory;
1750 }
1751
1752 /**
1753 * Process property details with memory tracking and optimization
1754 *
1755 * @param array $property The property data.
1756 * @param int $propertyId The property ID.
1757 * @param string $tipImport The import type.
1758 * @param array $propertyHistory The property history.
1759 * @param int $newAgent The new agent ID.
1760 * @param array $itemIdArray The item ID array.
1761 * @param string $isInsert If is a property insert
1762 */
1763 private function processPropertyDetails($property, $propertyId, $tipImport, &$propertyHistory, $newAgent, $itemIdArray, $isInsert) {
1764 global $mlsimport, $wpdb;
1765
1766
1767
1768 // Normalize timestamp fields in extra_meta to format like "May 17, 2025 at 06:26am"
1769 if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1770 $timestampFields = [
1771 'StatusChangeTimestamp',
1772 'STELLAR_BOMDate',
1773 'PriceChangeTimestamp',
1774 'PhotosChangeTimestamp',
1775 'BridgeModificationTimestamp',
1776 'ModificationTimestamp',
1777 'OriginalEntryTimestamp',
1778 'MajorChangeTimestamp'
1779 ];
1780
1781 foreach ($timestampFields as $tsField) {
1782 if (!empty($property['extra_meta'][$tsField])) {
1783 $timestamp = strtotime($property['extra_meta'][$tsField]);
1784 if ($timestamp !== false) {
1785 $property['extra_meta'][$tsField] = gmdate('F j, Y \a\t h:ia', $timestamp);
1786 }
1787 }
1788 }
1789 }
1790
1791
1792
1793 // 1. DISABLE AUTOCOMMIT FOR BATCH PROCESSING
1794 // This reduces memory by preventing DB auto-commits between operations
1795 if (method_exists($wpdb, 'query')) {
1796 $wpdb->query('SET autocommit = 0');
1797 }
1798
1799 // 2. TEMPORARY DISABLE ACTIONS THAT CONSUME MEMORY
1800 $suspended_actions = [];
1801 foreach (['save_post', 'added_post_meta', 'updated_post_meta'] as $action) {
1802 if (has_action($action)) {
1803 $suspended_actions[$action] = true;
1804 remove_all_actions($action);
1805 }
1806 }
1807
1808 // Initial memory
1809 $memStart = memory_get_usage(true);
1810
1811 $log = PHP_EOL . $this->mlsimportMemUsage() . '====before tax======' . PHP_EOL;
1812 $this->writeImportLogs($log, $tipImport);
1813
1814 // 3. OPTIMIZE TAXONOMY PROCESSING
1815 if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1816 $memBeforeTax = memory_get_usage(true);
1817
1818 // Load taxonomy mapping options
1819 $options = get_option('mlsimport_admin_fields_select');
1820 $theme_schema = mlsimport_hardocde_theme_schema();
1821 $taxonomy_overrides = array();
1822 if (isset($options['mls-fields-map-taxonomy']) && is_array($options['mls-fields-map-taxonomy'])) {
1823 foreach ($options['mls-fields-map-taxonomy'] as $field_key => $mapped_tax) {
1824 if ($mapped_tax === '') {
1825 continue;
1826 }
1827 if (isset($theme_schema[$field_key]) && isset($theme_schema[$field_key]['type']) &&
1828 $theme_schema[$field_key]['type'] === 'taxonomy' && isset($theme_schema[$field_key]['name'])) {
1829 $default_tax = $theme_schema[$field_key]['name'];
1830 if ($default_tax !== $mapped_tax) {
1831 $taxonomy_overrides[$default_tax] = $mapped_tax;
1832 }
1833 }
1834 }
1835 }
1836
1837 // Theme-agnostic override. The local hardcoded schema above is the
1838 // WPResidence mapping, so its default-taxonomy slugs do NOT match the
1839 // taxonomies the server built when a different theme (e.g. Houzez) was
1840 // used — the slug-based overrides then silently miss. For the core
1841 // fields that also arrive with a top-level copy, locate the field's
1842 // value inside the server-built taxonomies and redirect THAT taxonomy to
1843 // the user's mapped one. Result: "map field -> Category" moves the value
1844 // out of the server default and into the chosen taxonomy, on any theme.
1845 $core_field_value_sources = array(
1846 'StandardStatus' => 'StandardStatus',
1847 'PropertyType' => 'adr_type',
1848 'City' => 'adr_city',
1849 'CountyOrParish' => 'adr_county',
1850 );
1851 if (isset($options['mls-fields-map-taxonomy']) && is_array($options['mls-fields-map-taxonomy'])) {
1852 foreach ($core_field_value_sources as $reso_field => $top_level_key) {
1853 $mapped_tax = isset($options['mls-fields-map-taxonomy'][$reso_field]) ? $options['mls-fields-map-taxonomy'][$reso_field] : '';
1854 if ($mapped_tax === '' || !isset($property[$top_level_key]) || '' === $property[$top_level_key]) {
1855 continue;
1856 }
1857 $field_value = trim((string) $property[$top_level_key]);
1858 foreach ($property['taxonomies'] as $server_tax => $server_terms) {
1859 if ($server_tax === $mapped_tax) {
1860 continue;
1861 }
1862 $term_list = is_array($server_terms) ? $server_terms : array($server_terms);
1863 $term_list = array_map('trim', array_map('strval', $term_list));
1864 if (in_array($field_value, $term_list, true)) {
1865 $taxonomy_overrides[$server_tax] = $mapped_tax;
1866 }
1867 }
1868 }
1869 }
1870
1871 // Disable term counting temporarily (major memory saver)
1872 wp_defer_term_counting(true);
1873
1874 remove_filter('get_term_metadata', 'lazyload_term_meta', 10);
1875 wp_cache_delete('get_ancestors', 'taxonomy');
1876
1877 // Clear existing taxonomies
1878 $this->mlsimportSaasClearPropertyForTaxonomy($propertyId, $property['taxonomies']);
1879
1880 // 4. PROCESS TAXONOMIES IN CHUNKS
1881 $taxChunks = array_chunk($property['taxonomies'], 5, true);
1882 foreach ($taxChunks as $taxChunk) {
1883 foreach ($taxChunk as $taxonomy => $term) {
1884 if (isset($taxonomy_overrides[$taxonomy])) {
1885 $taxonomy = $taxonomy_overrides[$taxonomy];
1886 }
1887 wp_cache_delete("{$taxonomy}_term_counts", 'counts');
1888 $this->mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $term);
1889 $propertyHistory[] = 'Updated Taxonomy ' . $taxonomy . ' with terms ' . wp_json_encode($term);
1890
1891 // Memory cleanup after each taxonomy
1892 wp_cache_delete('term_meta', 'terms');
1893 wp_cache_delete($taxonomy, 'terms');
1894 }
1895
1896 // 5. FORCE GC AFTER EACH CHUNK
1897 gc_collect_cycles();
1898 }
1899
1900 // Restore term filter and clean up
1901 add_filter('get_term_metadata', 'lazyload_term_meta', 10, 2);
1902 delete_option('category_children');
1903
1904 // Re-enable term counting
1905 wp_defer_term_counting(false);
1906
1907 $memAfterTax = memory_get_usage(true);
1908 // " MB, Total Diff: " . round(($memAfterTax - $memBeforeTax) / 1048576, 2) . " MB");
1909 }
1910
1911 // 6. FLUSH SPECIFIC CACHES INSTEAD OF ALL
1912 // More targeted than wp_cache_flush()
1913 wp_cache_delete('terms', 'terms');
1914 wp_cache_delete('term_meta', 'terms');
1915 wp_cache_delete("post_meta_{$propertyId}", 'post_meta');
1916 wp_cache_delete($propertyId, 'posts');
1917
1918 // Prepare meta data
1919 $property = $this->mlsimportSaasPrepareMetaForProperty($property);
1920
1921 // 7. BATCH META UPDATES
1922 if (isset($property['meta']) && is_array($property['meta'])) {
1923 $memBeforeMeta = memory_get_usage(true);
1924 $metaCount = count($property['meta']);
1925
1926 // Use direct SQL for batch meta updates if many fields
1927 if ($metaCount > 0 && method_exists($wpdb, 'prepare')) {
1928 $meta_values = [];
1929 foreach ($property['meta'] as $metaName => $metaValue) {
1930 if (is_array($metaValue)) {
1931 $metaValue = implode(', ', array_map('trim', $metaValue));
1932 } else {
1933 $metaValue = preg_replace('/\s*,\s*/', ', ', trim($metaValue));
1934 }
1935
1936 // Build history separately
1937 $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1938
1939 // First delete existing
1940 $wpdb->delete(
1941 $wpdb->postmeta,
1942 ['post_id' => $propertyId, 'meta_key' => $metaName],
1943 ['%d', '%s']
1944 );
1945
1946 // Collect for batch insert
1947 $meta_values[] = $wpdb->prepare(
1948 "(%d, %s, %s)",
1949 $propertyId,
1950 $metaName,
1951 $metaValue
1952 );
1953 }
1954
1955 // Batch insert all meta at once
1956 if (!empty($meta_values)) {
1957 $wpdb->query("INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES " .
1958 implode(", ", $meta_values));
1959 }
1960 } else {
1961 // Dead code - left intentianaly
1962 // Standard approach for fewer meta fields
1963 foreach ($property['meta'] as $metaName => $metaValue) {
1964 if (is_array($metaValue)) {
1965 $metaValue = implode(', ', array_map('trim', $metaValue));
1966 } else {
1967 $metaValue = preg_replace('/\s*,\s*/', ', ', trim($metaValue));
1968 }
1969 update_post_meta($propertyId, $metaName, $metaValue);
1970 $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1971 }
1972 }
1973
1974 $memAfterMeta = memory_get_usage(true);
1975 // " MB, Diff: " . round(($memAfterMeta - $memBeforeMeta) / 1048576, 2) . " MB");
1976 }
1977
1978 // Extra meta processing
1979 $extraMetaResult = $mlsimport->admin->env_data->mlsimportSaasSetExtraMeta($propertyId, $property);
1980 if (isset($extraMetaResult['property_history'])) {
1981 $propertyHistory = array_merge($propertyHistory, (array)$extraMetaResult['property_history']);
1982 }
1983
1984 // 8. PROCESS MEDIA IN CHUNKS
1985 $memBeforeMedia = memory_get_usage(true);
1986
1987
1988 if (isset($property['Media']) && is_array($property['Media'])) {
1989 $media_attachments=array();
1990
1991 $mediaCount = count($property['Media']);
1992
1993 // Detect if media has changed for existing properties
1994 $shouldRefreshMedia = false;
1995 if ($isInsert === 'no') {
1996 $shouldRefreshMedia = $this->hasMediaChanged($propertyId, $property['Media']);
1997 if ($shouldRefreshMedia) {
1998 $this->writeImportLogs('Media changed for property ' . $propertyId . ', refreshing ' . $mediaCount . ' images', $tipImport);
1999 $this->deleteExistingMlsAttachments($propertyId);
2000 } else {
2001 $this->writeImportLogs('Media unchanged for property ' . $propertyId . ', skipping image refresh', $tipImport);
2002 }
2003 }
2004
2005 // Sort media by Order field if it exists
2006 if (isset($property['Media'][0]['Order'])) {
2007 $order = array_column($property['Media'], 'Order');
2008 array_multisort($order, SORT_ASC, $property['Media']);
2009 }
2010
2011 // Process in chunks of 5
2012 $mediaChunks = array_chunk($property['Media'], 5,true);
2013 $mediaHistoryParts = [];
2014
2015 // Clear original array to free memory
2016 $originalMedia = $property['Media'];
2017
2018 // Find featured image in single loop
2019 $featuredImageKey = null;
2020 $orderOneKey = null;
2021
2022 // First priority: Look for PreferredPhotoYN = 1
2023 foreach ($property['Media'] as $key => $mediaItem) {
2024 // Priority 1: PreferredPhotoYN = 1 (immediate selection)
2025 if (isset($mediaItem['PreferredPhotoYN']) && $mediaItem['PreferredPhotoYN'] == 1) {
2026 $featuredImageKey = $key;
2027 break;
2028 }
2029
2030
2031 // Priority 2: Store Order = 1 key for potential use
2032 if ($orderOneKey === null && isset($mediaItem['Order']) && $mediaItem['Order'] == 1) {
2033 $orderOneKey = $key;
2034 }
2035
2036 }
2037
2038 if ($featuredImageKey === null && $orderOneKey !== null) {
2039 $featuredImageKey = $orderOneKey;
2040 }
2041
2042 // Use Order = 1 image if no preferred image was found
2043 if ($featuredImageKey === null && $orderOneKey !== null) {
2044 $featuredImageKey = $orderOneKey;
2045 }
2046
2047 // Priority 3: Use first image if nothing else found
2048 if ($featuredImageKey === null && !empty($property['Media'])) {
2049 $featuredImageKey = 0;
2050 }
2051
2052
2053 unset($property['Media']);
2054
2055 if ($isInsert !== 'no' || $shouldRefreshMedia) {
2056 delete_post_meta($propertyId, 'fave_property_images');
2057 delete_post_meta($propertyId, 'REAL_HOMES_property_images');
2058 delete_post_meta($propertyId, 'wpestate_property_gallery');
2059 }
2060
2061
2062 foreach ($mediaChunks as $index => $mediaChunk) {
2063 $media_attachments = $this->mlsimportSassAttachMediaToPost($propertyId, $mediaChunk, $isInsert,$media_attachments,$featuredImageKey, $shouldRefreshMedia);
2064 // $mediaHistoryParts[] = $chunkHistory;
2065
2066 // Free memory
2067 unset($mediaChunk);
2068 //unset($chunkHistory);
2069 gc_collect_cycles();
2070
2071 // Incremental progress report
2072 }
2073
2074
2075 // Only rewrite the gallery when we actually (re)built the attachment list
2076 // (insert or media refresh). On the unchanged-media path $media_attachments
2077 // is empty, and overwriting would wipe the existing gallery.
2078 if ($isInsert !== 'no' || $shouldRefreshMedia) {
2079 $mlsimport->admin->env_data->enviroment_image_save_gallery($propertyId, $media_attachments);
2080 }
2081
2082 // Combine all chunks
2083 // $mediaHistory = implode('</br>', $mediaHistoryParts);
2084 // $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
2085
2086 // Clean up
2087 unset($mediaChunks);
2088 unset($mediaHistoryParts);
2089 unset($mediaHistory);
2090 unset($originalMedia);
2091 } else {
2092 $mediaHistory = $this->mlsimportSassAttachMediaToPost($propertyId, $property['Media'] ?? [], $isInsert,$featuredImageKey);
2093 $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
2094 }
2095
2096
2097 $memAfterMedia = memory_get_usage(true);
2098 // " MB, Diff: " . round(($memAfterMedia - $memBeforeMedia) / 1048576, 2) . " MB");
2099
2100 // Update title
2101 $newTitle = $this->mlsimportSaasUpdatePropertyTitle($propertyId, $itemIdArray['item_id'], $property);
2102 $propertyHistory[] = 'Updated title to ' . $newTitle . '</br>';
2103
2104 // Correlation update
2105 $mlsimport->admin->env_data->correlationUpdateAfter($isInsert, $propertyId, [], $newAgent);
2106
2107 // 9. COMMIT TRANSACTION
2108 if (method_exists($wpdb, 'query')) {
2109 $wpdb->query('COMMIT');
2110 $wpdb->query('SET autocommit = 1');
2111 }
2112
2113 // Save property history - using direct SQL if history is large
2114 if (!empty($propertyHistory)) {
2115 if (intval(get_option('mlsimport-disable-history', 1)) === 1) {
2116 $propertyHistory[] = '---------------------------------------------------------------</br>';
2117 $propertyHistory = implode('</br>', $propertyHistory);
2118
2119 // 10. USE DIRECT SQL FOR LARGE HISTORY
2120 if (strlen($propertyHistory) > 10000 && method_exists($wpdb, 'update')) {
2121 $wpdb->update(
2122 $wpdb->postmeta,
2123 ['meta_value' => $propertyHistory],
2124 ['post_id' => $propertyId, 'meta_key' => 'mlsimport_property_history'],
2125 ['%s'],
2126 ['%d', '%s']
2127 );
2128 } else {
2129 update_post_meta($propertyId, 'mlsimport_property_history', $propertyHistory);
2130 }
2131 }
2132 }
2133
2134 // 11. RESTORE ACTIONS
2135 if (!empty($suspended_actions)) {
2136 foreach ($suspended_actions as $action => $true) {
2137 add_action($action, '_wp_action_exists_' . $action);
2138 remove_action($action, '_wp_action_exists_' . $action);
2139 }
2140 }
2141
2142 // 12. FINAL CLEANUP
2143 $property = null;
2144 $propertyHistory = null;
2145 wp_cache_flush();
2146 gc_collect_cycles();
2147
2148 // Final memory stats
2149 $memEnd = memory_get_usage(true);
2150
2151 return $newTitle;
2152 }
2153
2154
2155 /**
2156 * Check if incoming MLS media differs from existing MLS-imported attachments.
2157 *
2158 * Compares incoming MediaURL values against the GUIDs of existing attachments
2159 * that have the is_mlsimport meta flag. Uses ID-only queries for memory efficiency.
2160 *
2161 * @param int $propertyId The property post ID.
2162 * @param array $incomingMedia Array of media items, each with a 'MediaURL' key.
2163 * @return bool True if images need refresh, false if unchanged.
2164 */
2165 private function hasMediaChanged($propertyId, $incomingMedia) {
2166 $existing = get_posts([
2167 'post_type' => 'attachment',
2168 'post_parent' => $propertyId,
2169 'post_status' => 'inherit',
2170 'meta_key' => 'is_mlsimport',
2171 'meta_value' => 1,
2172 'fields' => 'ids',
2173 'numberposts' => -1,
2174 ]);
2175
2176 $existingUrls = array_map(function ($id) {
2177 return get_post_field('guid', $id);
2178 }, $existing);
2179
2180 $incomingUrls = array_filter(array_column($incomingMedia, 'MediaURL'));
2181
2182 sort($existingUrls);
2183 sort($incomingUrls);
2184
2185 return $existingUrls !== $incomingUrls;
2186 }
2187
2188
2189 /**
2190 * Delete all MLS-imported attachments for a property.
2191 *
2192 * Only deletes attachments that have the is_mlsimport post meta set to 1.
2193 * Manually uploaded attachments are preserved.
2194 *
2195 * @param int $propertyId The property post ID.
2196 */
2197 private function deleteExistingMlsAttachments($propertyId) {
2198 $mlsAttachments = get_posts([
2199 'post_type' => 'attachment',
2200 'post_parent' => $propertyId,
2201 'post_status' => 'inherit',
2202 'meta_key' => 'is_mlsimport',
2203 'meta_value' => 1,
2204 'fields' => 'ids',
2205 'numberposts' => -1,
2206 ]);
2207
2208 foreach ($mlsAttachments as $attachId) {
2209 wp_delete_post($attachId, true);
2210 }
2211 }
2212
2213
2214
2215 }
2216