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

2,124 lines 67.9 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 // Increment the term count
711 $wpdb->query($wpdb->prepare(
712 "UPDATE $wpdb->term_taxonomy SET count = count + 1 WHERE term_taxonomy_id = %d",
713 $termTaxonomyId
714 ));
715 } else {
716 $taxLog[] = 'Error: term_taxonomy_id is null';
717 }
718 }
719 }
720 // Flush the cache to free up memory
721 wp_cache_flush();
722 // Run garbage collection
723 gc_collect_cycles();
724 }
725 // Commit the transaction
726 $wpdb->query('COMMIT');
727
728 // Clear term cache selectively
729 wp_cache_delete("{$taxonomy}_terms", 'terms');
730 wp_cache_delete("{$taxonomy}_children", 'terms');
731
732 // Restore the term metadata filter
733 add_filter('get_term_metadata', [$wpdb->terms, 'cache_term_counts'], 10, 2);
734
735 // Log memory usage
736 // if (!empty($taxLog)) {
737 // $taxLogStr = implode(PHP_EOL, $taxLog);
738 // mlsimport_saas_single_write_import_custom_logs($taxLogStr, 'normal');
739 // unset($taxLogStr);
740 // }
741 }
742
743
744
745
746 /**
747 * Set Property Title
748 *
749 * @param int $propertyId The property ID.
750 * @param int $mlsImportPostId The MLS import post ID.
751 * @param array $property The property data.
752 * @return string The updated title format.
753 */
754 public function mlsimportSaasUpdatePropertyTitle($propertyId, $mlsImportPostId, $property) {
755 global $mlsimport;
756
757 $titleFormat = esc_html(get_post_meta($mlsImportPostId, 'mlsimport_item_title_format', true));
758
759 if ('' === $titleFormat) {
760 $options = get_option('mlsimport_admin_mls_sync');
761 $titleFormat = $options['title_format'];
762 }
763
764 $titleArray = $this->strBetweenAll($titleFormat, '{', '}');
765
766 $propertyExtraMetaArrayLowerCase = array_change_key_case($property['extra_meta'], CASE_LOWER);
767
768 foreach ($titleArray as $key => $value) {
769 $replace = '';
770 switch ($value) {
771 case 'Address':
772 $replace = $property['adr_title'] ?? '';
773 break;
774 case 'City':
775 $replace = $property['adr_city'] ?? '';
776 break;
777 case 'CountyOrParish':
778 $replace = $property['adr_county'] ?? '';
779 break;
780 case 'PropertyType':
781 $replace = $property['adr_type'] ?? '';
782 break;
783 case 'Bedrooms':
784 $replace = $property['adr_bedrooms'] ?? '';
785 break;
786 case 'Bathrooms':
787 $replace = $property['adr_bathrooms'] ?? '';
788 break;
789 case 'ListingKey':
790 $replace = $property['ListingKey'];
791 break;
792 case 'ListingId':
793 $replace = $property['adr_listingid'] ?? '';
794 break;
795 case 'StateOrProvince':
796 $replace = $property['extra_meta']['StateOrProvince'] ?? '';
797 break;
798 case 'PostalCode':
799 $replace = $property['meta']['property_zip'] ?? $property['meta']['fave_property_zip'] ?? '';
800 $replace = is_array($replace) ? strval($replace[0]) : strval($replace);
801 break;
802 case 'StreetNumberNumeric':
803 $replace = $propertyExtraMetaArrayLowerCase['streetnumbernumeric'] ?? '';
804 break;
805 case 'StreetName':
806 $replace = $propertyExtraMetaArrayLowerCase['streetname'] ?? '';
807 break;
808 }
809 $titleFormat = str_replace('{' . $value . '}', $replace, $titleFormat);
810 }
811
812 $post = [
813 'ID' => $propertyId,
814 'post_title' => $titleFormat,
815 'post_name' => $titleFormat,
816 ];
817
818 wp_update_post($post);
819
820 return $titleFormat;
821 }
822
823
824
825
826
827
828 /**
829 * Prepare meta data for property
830 *
831 * @param array $property The property data.
832 * @return array The property data with prepared meta.
833 */
834 public function mlsimportSaasPrepareMetaForProperty($property) {
835 $bathroomsRaw = $property['extra_meta']['BathroomsTotalDecimal'] ?? '';
836 $bathrooms = ( '' === $bathroomsRaw || null === $bathroomsRaw ) ? '' : floatval($bathroomsRaw);
837 $property['meta']['property_bathrooms'] = $bathrooms;
838 $property['meta']['fave_property_bathrooms'] = $bathrooms;
839 $property['meta']['REAL_HOMES_property_bathrooms'] = $bathrooms;
840
841 // PostalCode is commonly provided in normalized meta (property_zip) rather than extra_meta.
842 // Mirror it into extra_meta when missing so field mappings (postmeta/taxonomy) can process it.
843 if (!isset($property['extra_meta']) || !is_array($property['extra_meta'])) {
844 $property['extra_meta'] = array();
845 }
846
847 $postal_code = '';
848 if (isset($property['meta']) && is_array($property['meta'])) {
849 if (!empty($property['meta']['property_zip'])) {
850 $postal_code = $property['meta']['property_zip'];
851 } elseif (!empty($property['meta']['fave_property_zip'])) {
852 $postal_code = $property['meta']['fave_property_zip'];
853 } elseif (!empty($property['meta']['REAL_HOMES_property_zip'])) {
854 $postal_code = $property['meta']['REAL_HOMES_property_zip'];
855 }
856 }
857
858 if (is_array($postal_code)) {
859 $postal_code = reset($postal_code);
860 }
861 $postal_code = trim((string) $postal_code);
862
863 if ('' !== $postal_code && empty($property['extra_meta']['PostalCode'])) {
864 $property['extra_meta']['PostalCode'] = $postal_code;
865 }
866
867 return $property;
868 }
869
870
871
872
873
874 /**
875 * Attach media to post
876 *
877 * @param int $propertyId The property ID.
878 * @param array $media The media data.
879 * @param string $isInsert Whether the property is being inserted.
880 * @return string The media history log.
881 */
882 public function mlsimportSassAttachMediaToPost($propertyId, $media, $isInsert,$media_attachments,$featuredImageKey, $shouldRefreshMedia = false) {
883
884 $mediaHistory = [];
885
886 if ($isInsert === 'no' && !$shouldRefreshMedia) {
887 $mediaHistory[] = 'Media - We have edit - images are not replaced';
888 return $media_attachments;
889 }
890
891 global $mlsimport;
892 include_once ABSPATH . 'wp-admin/includes/image.php';
893 $hasFeatured = false;
894
895
896
897
898 add_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']);
899
900
901 if (is_array($media)) {
902 foreach ($media as $key=>$image) {
903 if (isset($image['MediaCategory']) && $image['MediaCategory'] !== 'Property Photo' && $image['MediaCategory'] !== 'Photo') {
904 continue;
905 }
906
907 if ( empty( $image['MediaURL'] ) ) {
908 continue;
909 }
910
911
912
913 if (isset($image['MediaURL'])) {
914 $file = $image['MediaURL'];
915 $attachment = [
916 'guid' => $file,
917 'post_status' => 'inherit',
918 'post_content' => '',
919 'post_parent' => $propertyId,
920 'post_mime_type' => $image['MimeType'] ?? 'image/jpeg',
921 'post_title' => $image['MediaKey'] ?? '',
922 ];
923
924
925 $attachId = wp_insert_attachment($attachment, $file);
926 if (is_wp_error($attachId)) {
927 } else {
928 $mediaHistory[] = 'Media - Added ' . $file . ' as attachment ' . $attachId;
929 $media_attachments[]=$attachId;
930
931
932 $mlsimport->admin->env_data->enviroment_image_save($propertyId, $attachId);
933 update_post_meta($attachId, 'is_mlsimport', 1);
934
935 if ($key===$featuredImageKey){
936
937
938 set_post_thumbnail($propertyId, $attachId);
939
940 } else {
941 }
942 }
943 } else {
944 }
945 }
946 } else {
947 $mediaHistory[] = 'Media data is blank - there are no images';
948 }
949
950 remove_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']);
951
952 return $media_attachments;
953 //return implode('</br>', $mediaHistory);
954 }
955
956
957 /**
958 * Unset image sizes
959 *
960 * @param array $sizes The sizes to unset.
961 * @return array The modified sizes array.
962 */
963 public function wpcUnsetImageSizes($sizes) {
964 return [];
965 }
966
967
968
969
970
971
972
973 /**
974 * Return user option
975 *
976 * @param int $selected The selected user ID.
977 * @return string The HTML option elements for users.
978 */
979 public function mlsimportSaasThemeImportSelectUser($selected) {
980 $userOptions = '';
981 $blogusers = get_users(['blog_id' => 1, 'orderby' => 'nicename']);
982 foreach ($blogusers as $user) {
983 $userOptions .= '<option value="' . esc_attr($user->ID) . '"';
984 if ($user->ID == $selected) {
985 $userOptions .= ' selected="selected"';
986 }
987 $userOptions .= '>' . esc_html($user->user_login) . '</option>';
988 }
989 return $userOptions;
990 }
991
992
993
994
995
996
997
998 /**
999 * Return agent option
1000 *
1001 * @param int $selected The selected agent ID.
1002 * @return string The HTML option elements for agents.
1003 */
1004 public function mlsimportSaasThemeImportSelectAgent($selected) {
1005 global $mlsimport;
1006 $args = [
1007 'post_type' => $mlsimport->admin->env_data->get_agent_post_type(),
1008 'post_status' => 'publish',
1009 'posts_per_page' => 150,
1010 ];
1011
1012 $agentSelection = new WP_Query($args);
1013 $agentOptions = '<option value=""></option>';
1014
1015 while ($agentSelection->have_posts()) {
1016 $agentSelection->the_post();
1017 $agentId = get_the_ID();
1018
1019 $agentOptions .= '<option value="' . esc_attr($agentId) . '"';
1020 if ($agentId == $selected) {
1021 $agentOptions .= ' selected="selected"';
1022 }
1023 $agentOptions .= '>' . esc_html(get_the_title()) . '</option>';
1024 }
1025 wp_reset_postdata();
1026
1027 return $agentOptions;
1028 }
1029
1030
1031
1032
1033
1034
1035
1036
1037 /**
1038 * Delete property
1039 *
1040 * @param int $deleteId The ID of the property to delete.
1041 * @param string $ListingKey The listing key of the property.
1042 */
1043 public function deleteProperty($deleteId, $ListingKey) {
1044 if ($deleteId > 0) {
1045 mlsimport_record_activity( 'deleted', $deleteId, get_post_meta($deleteId,'ListingKey',true), intval(get_post_meta($deleteId,'MLSimport_item_inserted',true)), 'import' );
1046 $args = [
1047 'numberposts' => -1,
1048 'post_type' => 'attachment',
1049 'post_parent' => $deleteId,
1050 'post_status' => null,
1051 'orderby' => 'menu_order',
1052 'order' => 'ASC',
1053 ];
1054 $postAttachments = get_posts($args);
1055
1056 foreach ($postAttachments as $attachment) {
1057 wp_delete_post($attachment->ID);
1058 }
1059
1060 wp_delete_post($deleteId);
1061 mlsimport_telemetry_bump( 'deleted' );
1062 $logEntry = 'Property with id ' . $deleteId . ' and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
1063 $this->writeImportLogs($logEntry, 'delete');
1064 }
1065 }
1066
1067
1068
1069
1070 /**
1071 * Return array with title items
1072 *
1073 * @param string $string The input string.
1074 * @param string $start The start delimiter.
1075 * @param string $end The end delimiter.
1076 * @param bool $includeDelimiters Whether to include the delimiters in the result.
1077 * @param int $offset The offset to start searching from.
1078 * @return array The array of strings found between the delimiters.
1079 */
1080 public function strBetweenAll(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): array {
1081 $strings = [];
1082 $length = strlen($string);
1083
1084 while ($offset < $length) {
1085 $found = $this->strBetween($string, $start, $end, $includeDelimiters, $offset);
1086 if ($found === null) {
1087 break;
1088 }
1089
1090 $strings[] = $found;
1091 $offset += strlen($includeDelimiters ? $found : $start . $found . $end); // move offset to the end of the newfound string
1092 }
1093
1094 return $strings;
1095 }
1096
1097 /**
1098 * Find string between delimiters
1099 *
1100 * @param string $string The input string.
1101 * @param string $start The start delimiter.
1102 * @param string $end The end delimiter.
1103 * @param bool $includeDelimiters Whether to include the delimiters in the result.
1104 * @param int $offset The offset to start searching from.
1105 * @return string|null The string found between the delimiters, or null if not found.
1106 */
1107 public function strBetween(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): ?string {
1108 if ($string === '' || $start === '' || $end === '') {
1109 return null;
1110 }
1111
1112 $startLength = strlen($start);
1113 $endLength = strlen($end);
1114
1115 $startPos = strpos($string, $start, $offset);
1116 if ($startPos === false) {
1117 return null;
1118 }
1119
1120 $endPos = strpos($string, $end, $startPos + $startLength);
1121 if ($endPos === false) {
1122 return null;
1123 }
1124
1125 $length = $endPos - $startPos + ($includeDelimiters ? $endLength : -$startLength);
1126 if (!$length) {
1127 return '';
1128 }
1129
1130 $offset = $startPos + ($includeDelimiters ? 0 : $startLength);
1131
1132 return substr($string, $offset, $length);
1133 }
1134
1135
1136
1137
1138
1139 /**
1140 * Delete property via SQL
1141 *
1142 * @param int $deleteId The ID of the property to delete.
1143 * @param string $ListingKey The listing key of the property.
1144 */
1145 public function mlsimportSaasDeletePropertyViaMysql($deleteId, $ListingKey) {
1146 global $mlsimport;
1147
1148 $postType = get_post_type($deleteId);
1149 $propertyPostType = '';
1150 if (isset($mlsimport->admin->env_data) && method_exists($mlsimport->admin->env_data, 'get_property_post_type')) {
1151 $propertyPostType = $mlsimport->admin->env_data->get_property_post_type();
1152 }
1153
1154 if ($postType === $propertyPostType || in_array($postType, ['estate_property', 'property'])) {
1155 // Delete attachments using WordPress functions so the files are removed as well
1156 $attachments = get_posts([
1157 'numberposts' => -1,
1158 'post_type' => 'attachment',
1159 'post_parent' => $deleteId,
1160 'post_status' => null,
1161 'fields' => 'ids',
1162 ]);
1163
1164 foreach ($attachments as $attachmentId) {
1165 wp_delete_attachment($attachmentId, true);
1166 }
1167
1168 $termObjList = get_the_terms($deleteId, 'property_status');
1169 $deleteIdStatus = join(', ', wp_list_pluck($termObjList, 'name'));
1170
1171 $ListingKey = get_post_meta($deleteId, 'ListingKey', true);
1172 if ('' === $ListingKey) { // manually added listing
1173 $logEntry = 'User added listing with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' NOT DELETED' . PHP_EOL;
1174 $this->writeImportLogs($logEntry, 'delete');
1175 return;
1176 }
1177
1178 mlsimport_record_activity( 'deleted', $deleteId, $ListingKey, intval(get_post_meta($deleteId,'MLSimport_item_inserted',true)), 'reconciliation' );
1179
1180 global $wpdb;
1181 $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->postmeta WHERE `post_id` = %d", $deleteId));
1182 $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE `post_parent` = %d OR `ID` = %d", $deleteId, $deleteId));
1183 mlsimport_telemetry_bump( 'deleted' );
1184
1185 $logEntry = 'MYSQL DELETE -> Property with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
1186 $this->writeImportLogs($logEntry, 'delete');
1187 }
1188 }
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198 /**
1199 * Prepare to import per item
1200 *
1201 * @param array $property The property data.
1202 * @param array $itemIdArray The item ID array.
1203 * @param string $tipImport The import type.
1204 * @param array $mlsimportItemOptionData The item option data.
1205 */
1206 public function mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, $tipImport, $mlsimportItemOptionData) {
1207 // Pre-execution memory optimization
1208 wp_cache_flush();
1209 gc_collect_cycles();
1210
1211 // Temporarily disable WordPress hooks that might add to memory usage
1212 global $wp_filter;
1213 $saved_filters = array();
1214 if (isset($wp_filter['transition_post_status'])) {
1215 $saved_filters['transition_post_status'] = $wp_filter['transition_post_status'];
1216 unset($wp_filter['transition_post_status']);
1217 }
1218 if (isset($wp_filter['save_post'])) {
1219 $saved_filters['save_post'] = $wp_filter['save_post'];
1220 $wp_filter['save_post'] = new WP_Hook();
1221 }
1222 set_time_limit(0);
1223 global $mlsimport;
1224
1225 // Log initial memory
1226 $memStart = memory_get_usage(true);
1227 $memStartMB = round($memStart / 1048576, 2);
1228
1229 $mlsImportItemStatus = $mlsimportItemOptionData['mlsimport_item_standardstatus'];
1230 $newAuthor = $mlsimportItemOptionData['mlsimport_item_property_user'];
1231 $newAgent = $mlsimportItemOptionData['mlsimport_item_agent'];
1232 $propertyStatus = $mlsimportItemOptionData['mlsimport_item_property_status'];
1233
1234 if (is_array($mlsImportItemStatus)) {
1235 $mlsImportItemStatus = array_map('strtolower', $mlsImportItemStatus);
1236 }
1237
1238 if (!isset($property['ListingKey']) || empty($property['ListingKey'])) {
1239 $this->writeImportLogs('ERROR: No Listing Key ' . PHP_EOL, $tipImport);
1240 return;
1241 }
1242
1243 ob_start();
1244
1245 $ListingKey = $property['ListingKey'];
1246 $listingPostType = $mlsimport->admin->env_data->get_property_post_type();
1247
1248 // Memory before property ID lookup
1249 $memBeforeRetrieve = memory_get_usage(true);
1250
1251 $propertyId = intval($this->mlsimportSaasRetrievePropertyById($ListingKey, $listingPostType));
1252
1253 // Memory after property ID lookup
1254 $memAfterRetrieve = memory_get_usage(true);
1255
1256 $status = isset($property['StandardStatus']) ? strtolower($property['StandardStatus']) : strtolower($property['extra_meta']['MlsStatus']);
1257
1258 $this->writeImportLogs('FIxing: on inserting ' .$status.'-->'.json_encode($mlsImportItemStatus). PHP_EOL, $tipImport);
1259
1260 $isInsert = $this->shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport);
1261
1262 $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;
1263 $this->writeImportLogs($log, $tipImport);
1264
1265 $propertyHistory = [];
1266 $content = $property['content'] ?? '';
1267 $submitTitle = $ListingKey;
1268
1269 // Memory before insert/update
1270 $memBeforeInsert = memory_get_usage(true);
1271
1272 $activityAction = '';
1273
1274 if ($isInsert === 'yes') {
1275 $post = [
1276 'post_title' => $submitTitle,
1277 'post_content' => $content,
1278 'post_status' => $propertyStatus,
1279 'post_type' => $listingPostType,
1280 'post_author' => $newAuthor,
1281 ];
1282
1283 $propertyId = wp_insert_post($post);
1284
1285 if (is_wp_error($propertyId)) {
1286 $this->writeImportLogs('ERROR: on inserting ' . PHP_EOL, $tipImport);
1287 } else {
1288 update_post_meta($propertyId, 'ListingKey', $ListingKey);
1289 update_post_meta($propertyId, 'MLSimport_item_inserted', $itemIdArray['item_id'],);
1290 $activityAction = 'added';
1291 $propertyHistory[] = date('F j, Y, g:i a') . ': We Inserted the property with Default title : ' . $submitTitle . ' and received id:' . $propertyId;
1292 mlsimport_telemetry_bump( 'imported' );
1293 }
1294
1295 clean_post_cache($propertyId);
1296
1297 } elseif ($propertyId !== 0) {
1298
1299
1300 // Memory before checking existing property
1301 $memBeforeCheck = memory_get_usage(true);
1302
1303 $keep = $this->check_if_delete_when_status_on_manual_import($propertyId,$mlsImportItemStatus);
1304
1305
1306 if(!$keep){
1307 $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;
1308
1309 // Memory before delete
1310 $memBeforeDelete = memory_get_usage(true);
1311
1312 $this->deleteProperty($propertyId, $ListingKey);
1313
1314 // Memory after delete
1315 $memAfterDelete = memory_get_usage(true);
1316
1317 $this->writeImportLogs($log, $tipImport);
1318 } else {
1319 // Memory before updating
1320 $memBeforeUpdate = memory_get_usage(true);
1321
1322 $propertyHistory = $this->updateExistingProperty($propertyId, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, $propertyHistory, $tipImport, $ListingKey);
1323 $activityAction = 'edited';
1324
1325 // Memory after updating
1326 $memAfterUpdate = memory_get_usage(true);
1327 }
1328 }
1329
1330 // Memory after insert/update
1331 $memAfterInsert = memory_get_usage(true);
1332
1333 if ($propertyId === 0) {
1334 $this->writeImportLogs('ERROR property id is 0' . PHP_EOL, $tipImport);
1335 return;
1336 }
1337
1338 // Memory before processing details
1339 $memBeforeDetails = memory_get_usage(true);
1340
1341 $newTitle = $this->processPropertyDetails($property, $propertyId, $tipImport, $propertyHistory, $newAgent, $itemIdArray,$isInsert);
1342
1343 if ( $activityAction !== '' ) {
1344 mlsimport_record_activity( $activityAction, $propertyId, $ListingKey, isset($itemIdArray['item_id']) ? intval($itemIdArray['item_id']) : 0, $tipImport );
1345 }
1346
1347 // Memory after processing details
1348 $memAfterDetails = memory_get_usage(true);
1349
1350 $log = PHP_EOL . 'Ending on Property ' . $propertyId . ', ListingKey: ' . $ListingKey . ' , is insert? ' . $isInsert . ' with new title: ' . $newTitle . ' ' . PHP_EOL;
1351 $this->writeImportLogs($log, $tipImport);
1352
1353 clean_post_cache($propertyId);
1354
1355 // More aggressive memory cleanup
1356 // First clear specific large arrays in property data
1357 if (isset($property['Media']) && is_array($property['Media'])) {
1358 foreach ($property['Media'] as $key => $media) {
1359 unset($property['Media'][$key]);
1360 }
1361 }
1362 if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1363 foreach ($property['extra_meta'] as $key => $value) {
1364 unset($property['extra_meta'][$key]);
1365 }
1366 }
1367 if (isset($property['meta']) && is_array($property['meta'])) {
1368 foreach ($property['meta'] as $key => $value) {
1369 unset($property['meta'][$key]);
1370 }
1371 }
1372 if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1373 foreach ($property['taxonomies'] as $key => $value) {
1374 unset($property['taxonomies'][$key]);
1375 }
1376 }
1377
1378 // Then unset the main arrays
1379 unset($property['Media']);
1380 unset($property['extra_meta']);
1381 unset($property['meta']);
1382 unset($property['taxonomies']);
1383 unset($property);
1384
1385 // Clear any post caches that might have been created
1386 clean_post_cache($propertyId);
1387
1388 // Clear other variables that hold large data
1389 unset($log);
1390 unset($propertyHistory);
1391 $GLOBALS['wpdb']->queries = array();
1392
1393 // Clear WordPress specific caches
1394 wp_cache_delete('get_term_meta', 'terms');
1395 wp_cache_delete('terms', 'terms');
1396 wp_cache_delete('term_meta', 'terms');
1397 wp_cache_delete('get_terms', 'terms');
1398
1399 // Clear post related caches
1400 wp_cache_delete('post_meta_' . $propertyId, 'post_meta');
1401 wp_cache_delete($propertyId, 'posts');
1402
1403 // Force multiple garbage collection cycles
1404 gc_collect_cycles();
1405 gc_collect_cycles();
1406
1407 // Close and discard any output buffer content
1408 ob_end_clean();
1409
1410 // Try to trigger PHP's internal memory cleanup
1411 $dummy = str_repeat('x', 1024 * 1024);
1412 unset($dummy);
1413
1414 // Final memory usage
1415 $memEnd = memory_get_usage(true);
1416 $memEndMB = round($memEnd / 1048576, 2);
1417 $memDiff = round(($memEnd - $memStart) / 1048576, 2);
1418
1419 // If we see a significant memory increase, log a warning
1420 if ($memDiff > 5) {
1421 }
1422
1423 // Restore WordPress hooks
1424 global $wp_filter;
1425 if (!empty($saved_filters)) {
1426 foreach ($saved_filters as $hook => $filter) {
1427 $wp_filter[$hook] = $filter;
1428 }
1429 }
1430 }
1431
1432
1433
1434
1435
1436
1437 /**
1438 * Check if the property should be inserted
1439 *
1440 * @param int $propertyId The property ID.
1441 * @param string $status The property status.
1442 * @param array $mlsImportItemStatus The MLS import item status.
1443 * @param string $tipImport The import type.
1444 * @return string 'yes' or 'no' indicating if the property should be inserted.
1445 */
1446 private function shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport): string{
1447 $this->writeImportLogs(
1448 "Checking: on inserting {$propertyId}={$status} vs " .
1449 json_encode($mlsImportItemStatus) . " -- {$tipImport}" . PHP_EOL,
1450 $tipImport
1451 );
1452
1453
1454 if ($propertyId !== 0 || !is_array($mlsImportItemStatus)) {
1455 return 'no';
1456
1457 }
1458
1459 $activeStatuses = [
1460 'active',
1461 'active under contract',
1462 'active with contract',
1463 'activewithcontract',
1464 'status',
1465 'activeundercontract',
1466 'comingsoon',
1467 'coming soon',
1468 'pending'
1469 ];
1470 if(is_array($mlsImportItemStatus)){
1471 if (!in_array(strtolower($status), $mlsImportItemStatus, true)) {
1472 return 'no';
1473 }
1474
1475 if ($tipImport === 'cron' && !in_array($status, $mlsImportItemStatus, true)) {
1476 return 'no';
1477 }
1478
1479 }else{
1480 if(!in_array($status, $activeStatuses, true) ){
1481 return 'no';
1482 }
1483 }
1484
1485 return 'yes';
1486 }
1487
1488
1489 /**
1490 * Check for property status against MLS item delete status to see if we keep or delete the listing.
1491 * @param int $property_id
1492 * @param string|array $mlsImportItemStatus
1493 * @return bool True to keep, false to delete
1494 */
1495 public function check_if_delete_when_status($property_id, $mlsImportItemStatus, $mlsImportItemStatusDelete = null, $mlsImportItemStatusProtect = null) {
1496
1497 // Get post_status based on post type/taxonomy
1498 $post_status = '';
1499 if (post_type_exists('estate_property')) {
1500 $terms = get_the_terms($property_id, 'property_status');
1501 if (!empty($terms) && is_array($terms)) {
1502 $post_status = strtolower($terms[0]->name);
1503 }
1504 } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1505 $terms = get_the_terms($property_id, 'property_label');
1506 if (!empty($terms) && is_array($terms)) {
1507 $post_status = strtolower($terms[0]->name);
1508 }
1509 } else {
1510 $post_status = strtolower(get_post_meta($property_id, 'inspiry_property_label', true));
1511 }
1512
1513 // Protected statuses: keep if property status matches
1514 if (!empty($mlsImportItemStatusProtect)) {
1515 $mlsImportItemStatusProtect = is_array($mlsImportItemStatusProtect)
1516 ? array_map('strtolower', $mlsImportItemStatusProtect)
1517 : array(strtolower($mlsImportItemStatusProtect));
1518 if (in_array($post_status, $mlsImportItemStatusProtect, true)) {
1519 return true;
1520 }
1521 }
1522
1523 // Default: delete if not protected
1524 return false;
1525 }
1526
1527
1528
1529
1530 public function check_if_delete_when_status_on_manual_import($property_id, $mlsImportItemStatus) {
1531 // Normalize status arrays/strings to lowercase
1532 $mlsImportItemStatus = is_array($mlsImportItemStatus)
1533 ? array_map('strtolower', $mlsImportItemStatus)
1534 : strtolower($mlsImportItemStatus);
1535
1536 // Get post_status based on post type/taxonomy
1537 $post_status = '';
1538 if (post_type_exists('estate_property')) {
1539 $terms = get_the_terms($property_id, 'property_status');
1540 if (!empty($terms) && is_array($terms)) {
1541 $post_status = strtolower($terms[0]->name);
1542 }
1543 } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1544 $terms = get_the_terms($property_id, 'property_label');
1545 if (!empty($terms) && is_array($terms)) {
1546 $post_status = strtolower($terms[0]->name);
1547 }
1548 } else {
1549 $post_status = strtolower(get_post_meta($property_id, 'inspiry_property_label', true));
1550 }
1551
1552
1553
1554 // Keep if status matches "keep" status
1555 if ((is_array($mlsImportItemStatus) && in_array($post_status, $mlsImportItemStatus, true)) ||
1556 (!is_array($mlsImportItemStatus) && $post_status === $mlsImportItemStatus)) {
1557
1558 return true;
1559 }
1560
1561
1562
1563 // Default: keep
1564 return false;
1565 }
1566
1567
1568
1569
1570
1571 /**
1572 * Check if we should keep or delete the listing when still in MLS.
1573 * true we keep
1574 */
1575 public function check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect = null) {
1576 $post_status = '';
1577
1578 // Check for post status based on post type/taxonomy
1579 if (post_type_exists('estate_property')) {
1580 // WPResidence
1581 $terms = get_the_terms($property_id, 'property_status');
1582 if (!empty($terms) && is_array($terms)) {
1583 $post_status = strtolower($terms[0]->name);
1584 }
1585 } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1586 // Houzez
1587 $terms = get_the_terms($property_id, 'property_label');
1588 if (!empty($terms) && is_array($terms)) {
1589 $post_status = strtolower($terms[0]->name);
1590 }
1591 } else {
1592 // RealHomes
1593 $post_status = strtolower(get_post_meta($property_id, 'inspiry_property_label', true));
1594 }
1595
1596 // Protected statuses: keep if property status matches
1597 if (!empty($mlsimport_item_standardstatusprotect)) {
1598 $mlsimport_item_standardstatusprotect = is_array($mlsimport_item_standardstatusprotect)
1599 ? array_map('strtolower', $mlsimport_item_standardstatusprotect)
1600 : array(strtolower($mlsimport_item_standardstatusprotect));
1601 if (in_array($post_status, $mlsimport_item_standardstatusprotect, true)) {
1602 return true;
1603 }
1604 }
1605
1606 // Early return if MLS status empty
1607 if (empty($mlsimport_item_standardstatus)) {
1608 return true; // default: keep if no status set
1609 }
1610
1611 // Normalize standard statuses to lowercase for comparison
1612 if (is_array($mlsimport_item_standardstatus)) {
1613 $mlsimport_item_standardstatus = array_map('strtolower', $mlsimport_item_standardstatus);
1614 return in_array($post_status, $mlsimport_item_standardstatus, true);
1615 }
1616 return $post_status === strtolower($mlsimport_item_standardstatus);
1617 }
1618
1619
1620
1621
1622
1623
1624
1625 /**
1626 * Update existing property
1627 *
1628 * @param int $propertyId The property ID.
1629 * @param string $content The post content.
1630 * @param string $listingPostType The listing post type.
1631 * @param int $newAuthor The new author ID.
1632 * @param string $status The property status.
1633 * @param array $mlsImportItemStatus The MLS import item status.
1634 * @param array $propertyHistory The property history.
1635 * @param string $tipImport The import type.
1636 * @param string $ListingKey The listing key.
1637 * @return array Updated property history.
1638 */
1639 private function updateExistingProperty($propertyId, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, &$propertyHistory, $tipImport, $ListingKey) {
1640
1641
1642 $post = [
1643 'ID' => $propertyId,
1644 'post_content' => $content,
1645 'post_type' => $listingPostType,
1646 'post_author' => $newAuthor,
1647 ];
1648
1649 $log = 'Property with ID ' . $propertyId . ' and with name ' . get_the_title($propertyId) . ' has a status of <strong>' . $status . '</strong> and will be Edited</br>';
1650 $this->writeImportLogs($log, $tipImport);
1651
1652 $propertyId = wp_update_post($post);
1653 if (is_wp_error($propertyId)) {
1654 $this->writeImportLogs('ERROR: on edit ' . PHP_EOL, $tipImport);
1655 } else {
1656 $submitTitle = get_the_title($propertyId);
1657 $propertyHistory[] = gmdate('F j, Y, g:i a') . ': Property with title: ' . $submitTitle . ', id:' . $propertyId . ', ListingKey:' . $ListingKey . ', Status:' . $status . ' will be edited';
1658 mlsimport_telemetry_bump( 'updated' );
1659 }
1660 clean_post_cache( $propertyId );
1661
1662 return $propertyHistory;
1663 }
1664
1665 /**
1666 * Process property details with memory tracking and optimization
1667 *
1668 * @param array $property The property data.
1669 * @param int $propertyId The property ID.
1670 * @param string $tipImport The import type.
1671 * @param array $propertyHistory The property history.
1672 * @param int $newAgent The new agent ID.
1673 * @param array $itemIdArray The item ID array.
1674 * @param string $isInsert If is a property insert
1675 */
1676 private function processPropertyDetails($property, $propertyId, $tipImport, &$propertyHistory, $newAgent, $itemIdArray, $isInsert) {
1677 global $mlsimport, $wpdb;
1678
1679
1680
1681 // Normalize timestamp fields in extra_meta to format like "May 17, 2025 at 06:26am"
1682 if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1683 $timestampFields = [
1684 'StatusChangeTimestamp',
1685 'STELLAR_BOMDate',
1686 'PriceChangeTimestamp',
1687 'PhotosChangeTimestamp',
1688 'BridgeModificationTimestamp',
1689 'ModificationTimestamp',
1690 'OriginalEntryTimestamp',
1691 'MajorChangeTimestamp'
1692 ];
1693
1694 foreach ($timestampFields as $tsField) {
1695 if (!empty($property['extra_meta'][$tsField])) {
1696 $timestamp = strtotime($property['extra_meta'][$tsField]);
1697 if ($timestamp !== false) {
1698 $property['extra_meta'][$tsField] = gmdate('F j, Y \a\t h:ia', $timestamp);
1699 }
1700 }
1701 }
1702 }
1703
1704
1705
1706 // 1. DISABLE AUTOCOMMIT FOR BATCH PROCESSING
1707 // This reduces memory by preventing DB auto-commits between operations
1708 if (method_exists($wpdb, 'query')) {
1709 $wpdb->query('SET autocommit = 0');
1710 }
1711
1712 // 2. TEMPORARY DISABLE ACTIONS THAT CONSUME MEMORY
1713 $suspended_actions = [];
1714 foreach (['save_post', 'added_post_meta', 'updated_post_meta'] as $action) {
1715 if (has_action($action)) {
1716 $suspended_actions[$action] = true;
1717 remove_all_actions($action);
1718 }
1719 }
1720
1721 // Initial memory
1722 $memStart = memory_get_usage(true);
1723
1724 $log = PHP_EOL . $this->mlsimportMemUsage() . '====before tax======' . PHP_EOL;
1725 $this->writeImportLogs($log, $tipImport);
1726
1727 // 3. OPTIMIZE TAXONOMY PROCESSING
1728 if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1729 $memBeforeTax = memory_get_usage(true);
1730
1731 // Load taxonomy mapping options
1732 $options = get_option('mlsimport_admin_fields_select');
1733 $theme_schema = mlsimport_hardocde_theme_schema();
1734 $taxonomy_overrides = array();
1735 if (isset($options['mls-fields-map-taxonomy']) && is_array($options['mls-fields-map-taxonomy'])) {
1736 foreach ($options['mls-fields-map-taxonomy'] as $field_key => $mapped_tax) {
1737 if ($mapped_tax === '') {
1738 continue;
1739 }
1740 if (isset($theme_schema[$field_key]) && isset($theme_schema[$field_key]['type']) &&
1741 $theme_schema[$field_key]['type'] === 'taxonomy' && isset($theme_schema[$field_key]['name'])) {
1742 $default_tax = $theme_schema[$field_key]['name'];
1743 if ($default_tax !== $mapped_tax) {
1744 $taxonomy_overrides[$default_tax] = $mapped_tax;
1745 }
1746 }
1747 }
1748 }
1749
1750 // Theme-agnostic override. The local hardcoded schema above is the
1751 // WPResidence mapping, so its default-taxonomy slugs do NOT match the
1752 // taxonomies the server built when a different theme (e.g. Houzez) was
1753 // used — the slug-based overrides then silently miss. For the core
1754 // fields that also arrive with a top-level copy, locate the field's
1755 // value inside the server-built taxonomies and redirect THAT taxonomy to
1756 // the user's mapped one. Result: "map field -> Category" moves the value
1757 // out of the server default and into the chosen taxonomy, on any theme.
1758 $core_field_value_sources = array(
1759 'StandardStatus' => 'StandardStatus',
1760 'PropertyType' => 'adr_type',
1761 'City' => 'adr_city',
1762 'CountyOrParish' => 'adr_county',
1763 );
1764 if (isset($options['mls-fields-map-taxonomy']) && is_array($options['mls-fields-map-taxonomy'])) {
1765 foreach ($core_field_value_sources as $reso_field => $top_level_key) {
1766 $mapped_tax = isset($options['mls-fields-map-taxonomy'][$reso_field]) ? $options['mls-fields-map-taxonomy'][$reso_field] : '';
1767 if ($mapped_tax === '' || !isset($property[$top_level_key]) || '' === $property[$top_level_key]) {
1768 continue;
1769 }
1770 $field_value = trim((string) $property[$top_level_key]);
1771 foreach ($property['taxonomies'] as $server_tax => $server_terms) {
1772 if ($server_tax === $mapped_tax) {
1773 continue;
1774 }
1775 $term_list = is_array($server_terms) ? $server_terms : array($server_terms);
1776 $term_list = array_map('trim', array_map('strval', $term_list));
1777 if (in_array($field_value, $term_list, true)) {
1778 $taxonomy_overrides[$server_tax] = $mapped_tax;
1779 }
1780 }
1781 }
1782 }
1783
1784 // Disable term counting temporarily (major memory saver)
1785 wp_defer_term_counting(true);
1786
1787 remove_filter('get_term_metadata', 'lazyload_term_meta', 10);
1788 wp_cache_delete('get_ancestors', 'taxonomy');
1789
1790 // Clear existing taxonomies
1791 $this->mlsimportSaasClearPropertyForTaxonomy($propertyId, $property['taxonomies']);
1792
1793 // 4. PROCESS TAXONOMIES IN CHUNKS
1794 $taxChunks = array_chunk($property['taxonomies'], 5, true);
1795 foreach ($taxChunks as $taxChunk) {
1796 foreach ($taxChunk as $taxonomy => $term) {
1797 if (isset($taxonomy_overrides[$taxonomy])) {
1798 $taxonomy = $taxonomy_overrides[$taxonomy];
1799 }
1800 wp_cache_delete("{$taxonomy}_term_counts", 'counts');
1801 $this->mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $term);
1802 $propertyHistory[] = 'Updated Taxonomy ' . $taxonomy . ' with terms ' . wp_json_encode($term);
1803
1804 // Memory cleanup after each taxonomy
1805 wp_cache_delete('term_meta', 'terms');
1806 wp_cache_delete($taxonomy, 'terms');
1807 }
1808
1809 // 5. FORCE GC AFTER EACH CHUNK
1810 gc_collect_cycles();
1811 }
1812
1813 // Restore term filter and clean up
1814 add_filter('get_term_metadata', 'lazyload_term_meta', 10, 2);
1815 delete_option('category_children');
1816
1817 // Re-enable term counting
1818 wp_defer_term_counting(false);
1819
1820 $memAfterTax = memory_get_usage(true);
1821 // " MB, Total Diff: " . round(($memAfterTax - $memBeforeTax) / 1048576, 2) . " MB");
1822 }
1823
1824 // 6. FLUSH SPECIFIC CACHES INSTEAD OF ALL
1825 // More targeted than wp_cache_flush()
1826 wp_cache_delete('terms', 'terms');
1827 wp_cache_delete('term_meta', 'terms');
1828 wp_cache_delete("post_meta_{$propertyId}", 'post_meta');
1829 wp_cache_delete($propertyId, 'posts');
1830
1831 // Prepare meta data
1832 $property = $this->mlsimportSaasPrepareMetaForProperty($property);
1833
1834 // 7. BATCH META UPDATES
1835 if (isset($property['meta']) && is_array($property['meta'])) {
1836 $memBeforeMeta = memory_get_usage(true);
1837 $metaCount = count($property['meta']);
1838
1839 // Use direct SQL for batch meta updates if many fields
1840 if ($metaCount > 0 && method_exists($wpdb, 'prepare')) {
1841 $meta_values = [];
1842 foreach ($property['meta'] as $metaName => $metaValue) {
1843 if (is_array($metaValue)) {
1844 $metaValue = implode(', ', array_map('trim', $metaValue));
1845 } else {
1846 $metaValue = preg_replace('/\s*,\s*/', ', ', trim($metaValue));
1847 }
1848
1849 // Build history separately
1850 $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1851
1852 // First delete existing
1853 $wpdb->delete(
1854 $wpdb->postmeta,
1855 ['post_id' => $propertyId, 'meta_key' => $metaName],
1856 ['%d', '%s']
1857 );
1858
1859 // Collect for batch insert
1860 $meta_values[] = $wpdb->prepare(
1861 "(%d, %s, %s)",
1862 $propertyId,
1863 $metaName,
1864 $metaValue
1865 );
1866 }
1867
1868 // Batch insert all meta at once
1869 if (!empty($meta_values)) {
1870 $wpdb->query("INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES " .
1871 implode(", ", $meta_values));
1872 }
1873 } else {
1874 // Dead code - left intentianaly
1875 // Standard approach for fewer meta fields
1876 foreach ($property['meta'] as $metaName => $metaValue) {
1877 if (is_array($metaValue)) {
1878 $metaValue = implode(', ', array_map('trim', $metaValue));
1879 } else {
1880 $metaValue = preg_replace('/\s*,\s*/', ', ', trim($metaValue));
1881 }
1882 update_post_meta($propertyId, $metaName, $metaValue);
1883 $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1884 }
1885 }
1886
1887 $memAfterMeta = memory_get_usage(true);
1888 // " MB, Diff: " . round(($memAfterMeta - $memBeforeMeta) / 1048576, 2) . " MB");
1889 }
1890
1891 // Extra meta processing
1892 $extraMetaResult = $mlsimport->admin->env_data->mlsimportSaasSetExtraMeta($propertyId, $property);
1893 if (isset($extraMetaResult['property_history'])) {
1894 $propertyHistory = array_merge($propertyHistory, (array)$extraMetaResult['property_history']);
1895 }
1896
1897 // 8. PROCESS MEDIA IN CHUNKS
1898 $memBeforeMedia = memory_get_usage(true);
1899
1900
1901 if (isset($property['Media']) && is_array($property['Media'])) {
1902 $media_attachments=array();
1903
1904 $mediaCount = count($property['Media']);
1905
1906 // Detect if media has changed for existing properties
1907 $shouldRefreshMedia = false;
1908 if ($isInsert === 'no') {
1909 $shouldRefreshMedia = $this->hasMediaChanged($propertyId, $property['Media']);
1910 if ($shouldRefreshMedia) {
1911 $this->writeImportLogs('Media changed for property ' . $propertyId . ', refreshing ' . $mediaCount . ' images', $tipImport);
1912 $this->deleteExistingMlsAttachments($propertyId);
1913 } else {
1914 $this->writeImportLogs('Media unchanged for property ' . $propertyId . ', skipping image refresh', $tipImport);
1915 }
1916 }
1917
1918 // Sort media by Order field if it exists
1919 if (isset($property['Media'][0]['Order'])) {
1920 $order = array_column($property['Media'], 'Order');
1921 array_multisort($order, SORT_ASC, $property['Media']);
1922 }
1923
1924 // Process in chunks of 5
1925 $mediaChunks = array_chunk($property['Media'], 5,true);
1926 $mediaHistoryParts = [];
1927
1928 // Clear original array to free memory
1929 $originalMedia = $property['Media'];
1930
1931 // Find featured image in single loop
1932 $featuredImageKey = null;
1933 $orderOneKey = null;
1934
1935 // First priority: Look for PreferredPhotoYN = 1
1936 foreach ($property['Media'] as $key => $mediaItem) {
1937 // Priority 1: PreferredPhotoYN = 1 (immediate selection)
1938 if (isset($mediaItem['PreferredPhotoYN']) && $mediaItem['PreferredPhotoYN'] == 1) {
1939 $featuredImageKey = $key;
1940 break;
1941 }
1942
1943
1944 // Priority 2: Store Order = 1 key for potential use
1945 if ($orderOneKey === null && isset($mediaItem['Order']) && $mediaItem['Order'] == 1) {
1946 $orderOneKey = $key;
1947 }
1948
1949 }
1950
1951 if ($featuredImageKey === null && $orderOneKey !== null) {
1952 $featuredImageKey = $orderOneKey;
1953 }
1954
1955 // Use Order = 1 image if no preferred image was found
1956 if ($featuredImageKey === null && $orderOneKey !== null) {
1957 $featuredImageKey = $orderOneKey;
1958 }
1959
1960 // Priority 3: Use first image if nothing else found
1961 if ($featuredImageKey === null && !empty($property['Media'])) {
1962 $featuredImageKey = 0;
1963 }
1964
1965
1966 unset($property['Media']);
1967
1968 if ($isInsert !== 'no' || $shouldRefreshMedia) {
1969 delete_post_meta($propertyId, 'fave_property_images');
1970 delete_post_meta($propertyId, 'REAL_HOMES_property_images');
1971 delete_post_meta($propertyId, 'wpestate_property_gallery');
1972 }
1973
1974
1975 foreach ($mediaChunks as $index => $mediaChunk) {
1976 $media_attachments = $this->mlsimportSassAttachMediaToPost($propertyId, $mediaChunk, $isInsert,$media_attachments,$featuredImageKey, $shouldRefreshMedia);
1977 // $mediaHistoryParts[] = $chunkHistory;
1978
1979 // Free memory
1980 unset($mediaChunk);
1981 //unset($chunkHistory);
1982 gc_collect_cycles();
1983
1984 // Incremental progress report
1985 }
1986
1987
1988 $mlsimport->admin->env_data->enviroment_image_save_gallery($propertyId, $media_attachments);
1989
1990 // Combine all chunks
1991 // $mediaHistory = implode('</br>', $mediaHistoryParts);
1992 // $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
1993
1994 // Clean up
1995 unset($mediaChunks);
1996 unset($mediaHistoryParts);
1997 unset($mediaHistory);
1998 unset($originalMedia);
1999 } else {
2000 $mediaHistory = $this->mlsimportSassAttachMediaToPost($propertyId, $property['Media'] ?? [], $isInsert,$featuredImageKey);
2001 $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
2002 }
2003
2004
2005 $memAfterMedia = memory_get_usage(true);
2006 // " MB, Diff: " . round(($memAfterMedia - $memBeforeMedia) / 1048576, 2) . " MB");
2007
2008 // Update title
2009 $newTitle = $this->mlsimportSaasUpdatePropertyTitle($propertyId, $itemIdArray['item_id'], $property);
2010 $propertyHistory[] = 'Updated title to ' . $newTitle . '</br>';
2011
2012 // Correlation update
2013 $mlsimport->admin->env_data->correlationUpdateAfter($isInsert, $propertyId, [], $newAgent);
2014
2015 // 9. COMMIT TRANSACTION
2016 if (method_exists($wpdb, 'query')) {
2017 $wpdb->query('COMMIT');
2018 $wpdb->query('SET autocommit = 1');
2019 }
2020
2021 // Save property history - using direct SQL if history is large
2022 if (!empty($propertyHistory)) {
2023 if (intval(get_option('mlsimport-disable-history', 1)) === 1) {
2024 $propertyHistory[] = '---------------------------------------------------------------</br>';
2025 $propertyHistory = implode('</br>', $propertyHistory);
2026
2027 // 10. USE DIRECT SQL FOR LARGE HISTORY
2028 if (strlen($propertyHistory) > 10000 && method_exists($wpdb, 'update')) {
2029 $wpdb->update(
2030 $wpdb->postmeta,
2031 ['meta_value' => $propertyHistory],
2032 ['post_id' => $propertyId, 'meta_key' => 'mlsimport_property_history'],
2033 ['%s'],
2034 ['%d', '%s']
2035 );
2036 } else {
2037 update_post_meta($propertyId, 'mlsimport_property_history', $propertyHistory);
2038 }
2039 }
2040 }
2041
2042 // 11. RESTORE ACTIONS
2043 if (!empty($suspended_actions)) {
2044 foreach ($suspended_actions as $action => $true) {
2045 add_action($action, '_wp_action_exists_' . $action);
2046 remove_action($action, '_wp_action_exists_' . $action);
2047 }
2048 }
2049
2050 // 12. FINAL CLEANUP
2051 $property = null;
2052 $propertyHistory = null;
2053 wp_cache_flush();
2054 gc_collect_cycles();
2055
2056 // Final memory stats
2057 $memEnd = memory_get_usage(true);
2058
2059 return $newTitle;
2060 }
2061
2062
2063 /**
2064 * Check if incoming MLS media differs from existing MLS-imported attachments.
2065 *
2066 * Compares incoming MediaURL values against the GUIDs of existing attachments
2067 * that have the is_mlsimport meta flag. Uses ID-only queries for memory efficiency.
2068 *
2069 * @param int $propertyId The property post ID.
2070 * @param array $incomingMedia Array of media items, each with a 'MediaURL' key.
2071 * @return bool True if images need refresh, false if unchanged.
2072 */
2073 private function hasMediaChanged($propertyId, $incomingMedia) {
2074 $existing = get_posts([
2075 'post_type' => 'attachment',
2076 'post_parent' => $propertyId,
2077 'post_status' => 'inherit',
2078 'meta_key' => 'is_mlsimport',
2079 'meta_value' => 1,
2080 'fields' => 'ids',
2081 'numberposts' => -1,
2082 ]);
2083
2084 $existingUrls = array_map(function ($id) {
2085 return get_post_field('guid', $id);
2086 }, $existing);
2087
2088 $incomingUrls = array_filter(array_column($incomingMedia, 'MediaURL'));
2089
2090 sort($existingUrls);
2091 sort($incomingUrls);
2092
2093 return $existingUrls !== $incomingUrls;
2094 }
2095
2096
2097 /**
2098 * Delete all MLS-imported attachments for a property.
2099 *
2100 * Only deletes attachments that have the is_mlsimport post meta set to 1.
2101 * Manually uploaded attachments are preserved.
2102 *
2103 * @param int $propertyId The property post ID.
2104 */
2105 private function deleteExistingMlsAttachments($propertyId) {
2106 $mlsAttachments = get_posts([
2107 'post_type' => 'attachment',
2108 'post_parent' => $propertyId,
2109 'post_status' => 'inherit',
2110 'meta_key' => 'is_mlsimport',
2111 'meta_value' => 1,
2112 'fields' => 'ids',
2113 'numberposts' => -1,
2114 ]);
2115
2116 foreach ($mlsAttachments as $attachId) {
2117 wp_delete_post($attachId, true);
2118 }
2119 }
2120
2121
2122
2123 }
2124