PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 6.3.4
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v6.3.4
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 6.0.7 All 35 releases
mlsimport / includes / ThemeImport.php

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

2,175 lines 70.5 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('strtolower', $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']) ? strtolower($property['StandardStatus']) : strtolower($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 if ($isInsert === 'yes') {
1303 $post = [
1304 'post_title' => $submitTitle,
1305 'post_content' => $content,
1306 'post_status' => $propertyStatus,
1307 'post_type' => $listingPostType,
1308 'post_author' => $newAuthor,
1309 ];
1310
1311 $propertyId = wp_insert_post($post);
1312
1313 if (is_wp_error($propertyId)) {
1314 $this->writeImportLogs('ERROR: on inserting ' . PHP_EOL, $tipImport);
1315 } else {
1316 update_post_meta($propertyId, 'ListingKey', $ListingKey);
1317 update_post_meta($propertyId, 'MLSimport_item_inserted', $itemIdArray['item_id'],);
1318 $activityAction = 'added';
1319 $propertyHistory[] = date('F j, Y, g:i a') . ': We Inserted the property with Default title : ' . $submitTitle . ' and received id:' . $propertyId;
1320 mlsimport_telemetry_bump( 'imported' );
1321 }
1322
1323 clean_post_cache($propertyId);
1324
1325 } elseif ($propertyId !== 0) {
1326
1327
1328 // Memory before checking existing property
1329 $memBeforeCheck = memory_get_usage(true);
1330
1331 $keep = $this->shouldKeepExistingListing($status, $mlsImportItemStatus);
1332
1333
1334 if(!$keep){
1335 $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;
1336
1337 // Memory before delete
1338 $memBeforeDelete = memory_get_usage(true);
1339
1340 $this->deleteProperty($propertyId, $ListingKey);
1341
1342 // Memory after delete
1343 $memAfterDelete = memory_get_usage(true);
1344
1345 $this->writeImportLogs($log, $tipImport);
1346 } else {
1347 // Memory before updating
1348 $memBeforeUpdate = memory_get_usage(true);
1349
1350 $propertyHistory = $this->updateExistingProperty($propertyId, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, $propertyHistory, $tipImport, $ListingKey);
1351 $activityAction = 'edited';
1352
1353 // Memory after updating
1354 $memAfterUpdate = memory_get_usage(true);
1355 }
1356 }
1357
1358 // Memory after insert/update
1359 $memAfterInsert = memory_get_usage(true);
1360
1361 if ($propertyId === 0) {
1362 $this->writeImportLogs('ERROR property id is 0' . PHP_EOL, $tipImport);
1363 return;
1364 }
1365
1366 // Memory before processing details
1367 $memBeforeDetails = memory_get_usage(true);
1368
1369 $newTitle = $this->processPropertyDetails($property, $propertyId, $tipImport, $propertyHistory, $newAgent, $itemIdArray,$isInsert);
1370
1371 if ( $activityAction !== '' ) {
1372 mlsimport_record_activity( $activityAction, $propertyId, $ListingKey, isset($itemIdArray['item_id']) ? intval($itemIdArray['item_id']) : 0, $tipImport );
1373 }
1374
1375 // Memory after processing details
1376 $memAfterDetails = memory_get_usage(true);
1377
1378 $log = PHP_EOL . 'Ending on Property ' . $propertyId . ', ListingKey: ' . $ListingKey . ' , is insert? ' . $isInsert . ' with new title: ' . $newTitle . ' ' . PHP_EOL;
1379 $this->writeImportLogs($log, $tipImport);
1380
1381 clean_post_cache($propertyId);
1382
1383 // More aggressive memory cleanup
1384 // First clear specific large arrays in property data
1385 if (isset($property['Media']) && is_array($property['Media'])) {
1386 foreach ($property['Media'] as $key => $media) {
1387 unset($property['Media'][$key]);
1388 }
1389 }
1390 if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1391 foreach ($property['extra_meta'] as $key => $value) {
1392 unset($property['extra_meta'][$key]);
1393 }
1394 }
1395 if (isset($property['meta']) && is_array($property['meta'])) {
1396 foreach ($property['meta'] as $key => $value) {
1397 unset($property['meta'][$key]);
1398 }
1399 }
1400 if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1401 foreach ($property['taxonomies'] as $key => $value) {
1402 unset($property['taxonomies'][$key]);
1403 }
1404 }
1405
1406 // Then unset the main arrays
1407 unset($property['Media']);
1408 unset($property['extra_meta']);
1409 unset($property['meta']);
1410 unset($property['taxonomies']);
1411 unset($property);
1412
1413 // Clear any post caches that might have been created
1414 clean_post_cache($propertyId);
1415
1416 // Clear other variables that hold large data
1417 unset($log);
1418 unset($propertyHistory);
1419 $GLOBALS['wpdb']->queries = array();
1420
1421 // Clear WordPress specific caches
1422 wp_cache_delete('get_term_meta', 'terms');
1423 wp_cache_delete('terms', 'terms');
1424 wp_cache_delete('term_meta', 'terms');
1425 wp_cache_delete('get_terms', 'terms');
1426
1427 // Clear post related caches
1428 wp_cache_delete('post_meta_' . $propertyId, 'post_meta');
1429 wp_cache_delete($propertyId, 'posts');
1430
1431 // Force multiple garbage collection cycles
1432 gc_collect_cycles();
1433 gc_collect_cycles();
1434
1435 // Close and discard any output buffer content
1436 ob_end_clean();
1437
1438 // Try to trigger PHP's internal memory cleanup
1439 $dummy = str_repeat('x', 1024 * 1024);
1440 unset($dummy);
1441
1442 // Final memory usage
1443 $memEnd = memory_get_usage(true);
1444 $memEndMB = round($memEnd / 1048576, 2);
1445 $memDiff = round(($memEnd - $memStart) / 1048576, 2);
1446
1447 // If we see a significant memory increase, log a warning
1448 if ($memDiff > 5) {
1449 }
1450
1451 // Restore WordPress hooks
1452 global $wp_filter;
1453 if (!empty($saved_filters)) {
1454 foreach ($saved_filters as $hook => $filter) {
1455 $wp_filter[$hook] = $filter;
1456 }
1457 }
1458 }
1459
1460
1461
1462
1463
1464
1465 /**
1466 * Check if the property should be inserted
1467 *
1468 * @param int $propertyId The property ID.
1469 * @param string $status The property status.
1470 * @param array $mlsImportItemStatus The MLS import item status.
1471 * @param string $tipImport The import type.
1472 * @return string 'yes' or 'no' indicating if the property should be inserted.
1473 */
1474 private function shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport): string{
1475 $this->writeImportLogs(
1476 "Checking: on inserting {$propertyId}={$status} vs " .
1477 json_encode($mlsImportItemStatus) . " -- {$tipImport}" . PHP_EOL,
1478 $tipImport
1479 );
1480
1481
1482 if ($propertyId !== 0 || !is_array($mlsImportItemStatus)) {
1483 return 'no';
1484
1485 }
1486
1487 $activeStatuses = [
1488 'active',
1489 'active under contract',
1490 'active with contract',
1491 'activewithcontract',
1492 'status',
1493 'activeundercontract',
1494 'comingsoon',
1495 'coming soon',
1496 'pending'
1497 ];
1498 if(is_array($mlsImportItemStatus)){
1499 if (!in_array(strtolower($status), $mlsImportItemStatus, true)) {
1500 return 'no';
1501 }
1502
1503 if ($tipImport === 'cron' && !in_array($status, $mlsImportItemStatus, true)) {
1504 return 'no';
1505 }
1506
1507 }else{
1508 if(!in_array($status, $activeStatuses, true) ){
1509 return 'no';
1510 }
1511 }
1512
1513 return 'yes';
1514 }
1515
1516
1517 /**
1518 * Check for property status against MLS item delete status to see if we keep or delete the listing.
1519 * @param int $property_id
1520 * @param string|array $mlsImportItemStatus
1521 * @return bool True to keep, false to delete
1522 */
1523 public function check_if_delete_when_status($property_id, $mlsImportItemStatus, $mlsImportItemStatusDelete = null, $mlsImportItemStatusProtect = null) {
1524
1525 // Get post_status based on post type/taxonomy
1526 $post_status = '';
1527 if (post_type_exists('estate_property')) {
1528 $terms = get_the_terms($property_id, 'property_status');
1529 if (!empty($terms) && is_array($terms)) {
1530 $post_status = strtolower($terms[0]->name);
1531 }
1532 } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1533 $terms = get_the_terms($property_id, 'property_label');
1534 if (!empty($terms) && is_array($terms)) {
1535 $post_status = strtolower($terms[0]->name);
1536 }
1537 } else {
1538 $post_status = strtolower(get_post_meta($property_id, 'inspiry_property_label', true));
1539 }
1540
1541 // Protected statuses: keep if property status matches
1542 if (!empty($mlsImportItemStatusProtect)) {
1543 $mlsImportItemStatusProtect = is_array($mlsImportItemStatusProtect)
1544 ? array_map('strtolower', $mlsImportItemStatusProtect)
1545 : array(strtolower($mlsImportItemStatusProtect));
1546 if (in_array($post_status, $mlsImportItemStatusProtect, true)) {
1547 return true;
1548 }
1549 }
1550
1551 // Default: delete if not protected
1552 return false;
1553 }
1554
1555
1556
1557
1558 public function check_if_delete_when_status_on_manual_import($property_id, $mlsImportItemStatus) {
1559 // Normalize status arrays/strings to lowercase
1560 $mlsImportItemStatus = is_array($mlsImportItemStatus)
1561 ? array_map('strtolower', $mlsImportItemStatus)
1562 : strtolower($mlsImportItemStatus);
1563
1564 // Get post_status based on post type/taxonomy
1565 $post_status = '';
1566 if (post_type_exists('estate_property')) {
1567 $terms = get_the_terms($property_id, 'property_status');
1568 if (!empty($terms) && is_array($terms)) {
1569 $post_status = strtolower($terms[0]->name);
1570 }
1571 } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1572 $terms = get_the_terms($property_id, 'property_label');
1573 if (!empty($terms) && is_array($terms)) {
1574 $post_status = strtolower($terms[0]->name);
1575 }
1576 } else {
1577 $post_status = strtolower(get_post_meta($property_id, 'inspiry_property_label', true));
1578 }
1579
1580
1581
1582 // Keep if status matches "keep" status
1583 if ((is_array($mlsImportItemStatus) && in_array($post_status, $mlsImportItemStatus, true)) ||
1584 (!is_array($mlsImportItemStatus) && $post_status === $mlsImportItemStatus)) {
1585
1586 return true;
1587 }
1588
1589
1590
1591 // Default: keep
1592 return false;
1593 }
1594
1595
1596 /**
1597 * Decide whether to keep an existing listing the (filtered) feed returned again.
1598 *
1599 * Uses the live MLS status — the SAME basis shouldInsertProperty() uses to
1600 * decide an insert. The old code compared the stored property_status taxonomy
1601 * term instead, which can be empty, remapped, or theme-labeled; when it did not
1602 * equal the RESO status, keep said "delete" while insert said "yes", producing
1603 * an add/delete/add cycle on every sync. See issue #152.
1604 *
1605 * @param string $status Live MLS StandardStatus (lowercased).
1606 * @param array|string $mlsImportItemStatus Task's selected RESO statuses (lowercased).
1607 * @return bool True to keep/update, false to delete.
1608 */
1609 public function shouldKeepExistingListing($status, $mlsImportItemStatus): bool {
1610 return is_array($mlsImportItemStatus) && in_array($status, $mlsImportItemStatus, true);
1611 }
1612
1613
1614
1615
1616
1617 /**
1618 * Check if we should keep or delete the listing when still in MLS.
1619 * true we keep
1620 */
1621 public function check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect = null) {
1622 $post_status = '';
1623
1624 // Check for post status based on post type/taxonomy
1625 if (post_type_exists('estate_property')) {
1626 // WPResidence
1627 $terms = get_the_terms($property_id, 'property_status');
1628 if (!empty($terms) && is_array($terms)) {
1629 $post_status = strtolower($terms[0]->name);
1630 }
1631 } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1632 // Houzez
1633 $terms = get_the_terms($property_id, 'property_label');
1634 if (!empty($terms) && is_array($terms)) {
1635 $post_status = strtolower($terms[0]->name);
1636 }
1637 } else {
1638 // RealHomes
1639 $post_status = strtolower(get_post_meta($property_id, 'inspiry_property_label', true));
1640 }
1641
1642 // Protected statuses: keep if property status matches
1643 if (!empty($mlsimport_item_standardstatusprotect)) {
1644 $mlsimport_item_standardstatusprotect = is_array($mlsimport_item_standardstatusprotect)
1645 ? array_map('strtolower', $mlsimport_item_standardstatusprotect)
1646 : array(strtolower($mlsimport_item_standardstatusprotect));
1647 if (in_array($post_status, $mlsimport_item_standardstatusprotect, true)) {
1648 return true;
1649 }
1650 }
1651
1652 // Early return if MLS status empty
1653 if (empty($mlsimport_item_standardstatus)) {
1654 return true; // default: keep if no status set
1655 }
1656
1657 // Normalize standard statuses to lowercase for comparison
1658 if (is_array($mlsimport_item_standardstatus)) {
1659 $mlsimport_item_standardstatus = array_map('strtolower', $mlsimport_item_standardstatus);
1660 return in_array($post_status, $mlsimport_item_standardstatus, true);
1661 }
1662 return $post_status === strtolower($mlsimport_item_standardstatus);
1663 }
1664
1665
1666
1667
1668
1669
1670
1671 /**
1672 * Update existing property
1673 *
1674 * @param int $propertyId The property ID.
1675 * @param string $content The post content.
1676 * @param string $listingPostType The listing post type.
1677 * @param int $newAuthor The new author ID.
1678 * @param string $status The property status.
1679 * @param array $mlsImportItemStatus The MLS import item status.
1680 * @param array $propertyHistory The property history.
1681 * @param string $tipImport The import type.
1682 * @param string $ListingKey The listing key.
1683 * @return array Updated property history.
1684 */
1685 private function updateExistingProperty($propertyId, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, &$propertyHistory, $tipImport, $ListingKey) {
1686
1687
1688 $post = [
1689 'ID' => $propertyId,
1690 'post_content' => $content,
1691 'post_type' => $listingPostType,
1692 'post_author' => $newAuthor,
1693 ];
1694
1695 $log = 'Property with ID ' . $propertyId . ' and with name ' . get_the_title($propertyId) . ' has a status of <strong>' . $status . '</strong> and will be Edited</br>';
1696 $this->writeImportLogs($log, $tipImport);
1697
1698 $propertyId = wp_update_post($post);
1699 if (is_wp_error($propertyId)) {
1700 $this->writeImportLogs('ERROR: on edit ' . PHP_EOL, $tipImport);
1701 } else {
1702 $submitTitle = get_the_title($propertyId);
1703 $propertyHistory[] = gmdate('F j, Y, g:i a') . ': Property with title: ' . $submitTitle . ', id:' . $propertyId . ', ListingKey:' . $ListingKey . ', Status:' . $status . ' will be edited';
1704 mlsimport_telemetry_bump( 'updated' );
1705 }
1706 clean_post_cache( $propertyId );
1707
1708 return $propertyHistory;
1709 }
1710
1711 /**
1712 * Process property details with memory tracking and optimization
1713 *
1714 * @param array $property The property data.
1715 * @param int $propertyId The property ID.
1716 * @param string $tipImport The import type.
1717 * @param array $propertyHistory The property history.
1718 * @param int $newAgent The new agent ID.
1719 * @param array $itemIdArray The item ID array.
1720 * @param string $isInsert If is a property insert
1721 */
1722 private function processPropertyDetails($property, $propertyId, $tipImport, &$propertyHistory, $newAgent, $itemIdArray, $isInsert) {
1723 global $mlsimport, $wpdb;
1724
1725
1726
1727 // Normalize timestamp fields in extra_meta to format like "May 17, 2025 at 06:26am"
1728 if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1729 $timestampFields = [
1730 'StatusChangeTimestamp',
1731 'STELLAR_BOMDate',
1732 'PriceChangeTimestamp',
1733 'PhotosChangeTimestamp',
1734 'BridgeModificationTimestamp',
1735 'ModificationTimestamp',
1736 'OriginalEntryTimestamp',
1737 'MajorChangeTimestamp'
1738 ];
1739
1740 foreach ($timestampFields as $tsField) {
1741 if (!empty($property['extra_meta'][$tsField])) {
1742 $timestamp = strtotime($property['extra_meta'][$tsField]);
1743 if ($timestamp !== false) {
1744 $property['extra_meta'][$tsField] = gmdate('F j, Y \a\t h:ia', $timestamp);
1745 }
1746 }
1747 }
1748 }
1749
1750
1751
1752 // 1. DISABLE AUTOCOMMIT FOR BATCH PROCESSING
1753 // This reduces memory by preventing DB auto-commits between operations
1754 if (method_exists($wpdb, 'query')) {
1755 $wpdb->query('SET autocommit = 0');
1756 }
1757
1758 // 2. TEMPORARY DISABLE ACTIONS THAT CONSUME MEMORY
1759 $suspended_actions = [];
1760 foreach (['save_post', 'added_post_meta', 'updated_post_meta'] as $action) {
1761 if (has_action($action)) {
1762 $suspended_actions[$action] = true;
1763 remove_all_actions($action);
1764 }
1765 }
1766
1767 // Initial memory
1768 $memStart = memory_get_usage(true);
1769
1770 $log = PHP_EOL . $this->mlsimportMemUsage() . '====before tax======' . PHP_EOL;
1771 $this->writeImportLogs($log, $tipImport);
1772
1773 // 3. OPTIMIZE TAXONOMY PROCESSING
1774 if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1775 $memBeforeTax = memory_get_usage(true);
1776
1777 // Load taxonomy mapping options
1778 $options = get_option('mlsimport_admin_fields_select');
1779 $theme_schema = mlsimport_hardocde_theme_schema();
1780 $taxonomy_overrides = array();
1781 if (isset($options['mls-fields-map-taxonomy']) && is_array($options['mls-fields-map-taxonomy'])) {
1782 foreach ($options['mls-fields-map-taxonomy'] as $field_key => $mapped_tax) {
1783 if ($mapped_tax === '') {
1784 continue;
1785 }
1786 if (isset($theme_schema[$field_key]) && isset($theme_schema[$field_key]['type']) &&
1787 $theme_schema[$field_key]['type'] === 'taxonomy' && isset($theme_schema[$field_key]['name'])) {
1788 $default_tax = $theme_schema[$field_key]['name'];
1789 if ($default_tax !== $mapped_tax) {
1790 $taxonomy_overrides[$default_tax] = $mapped_tax;
1791 }
1792 }
1793 }
1794 }
1795
1796 // Theme-agnostic override. The local hardcoded schema above is the
1797 // WPResidence mapping, so its default-taxonomy slugs do NOT match the
1798 // taxonomies the server built when a different theme (e.g. Houzez) was
1799 // used — the slug-based overrides then silently miss. For the core
1800 // fields that also arrive with a top-level copy, locate the field's
1801 // value inside the server-built taxonomies and redirect THAT taxonomy to
1802 // the user's mapped one. Result: "map field -> Category" moves the value
1803 // out of the server default and into the chosen taxonomy, on any theme.
1804 $core_field_value_sources = array(
1805 'StandardStatus' => 'StandardStatus',
1806 'PropertyType' => 'adr_type',
1807 'City' => 'adr_city',
1808 'CountyOrParish' => 'adr_county',
1809 );
1810 if (isset($options['mls-fields-map-taxonomy']) && is_array($options['mls-fields-map-taxonomy'])) {
1811 foreach ($core_field_value_sources as $reso_field => $top_level_key) {
1812 $mapped_tax = isset($options['mls-fields-map-taxonomy'][$reso_field]) ? $options['mls-fields-map-taxonomy'][$reso_field] : '';
1813 if ($mapped_tax === '' || !isset($property[$top_level_key]) || '' === $property[$top_level_key]) {
1814 continue;
1815 }
1816 $field_value = trim((string) $property[$top_level_key]);
1817 foreach ($property['taxonomies'] as $server_tax => $server_terms) {
1818 if ($server_tax === $mapped_tax) {
1819 continue;
1820 }
1821 $term_list = is_array($server_terms) ? $server_terms : array($server_terms);
1822 $term_list = array_map('trim', array_map('strval', $term_list));
1823 if (in_array($field_value, $term_list, true)) {
1824 $taxonomy_overrides[$server_tax] = $mapped_tax;
1825 }
1826 }
1827 }
1828 }
1829
1830 // Disable term counting temporarily (major memory saver)
1831 wp_defer_term_counting(true);
1832
1833 remove_filter('get_term_metadata', 'lazyload_term_meta', 10);
1834 wp_cache_delete('get_ancestors', 'taxonomy');
1835
1836 // Clear existing taxonomies
1837 $this->mlsimportSaasClearPropertyForTaxonomy($propertyId, $property['taxonomies']);
1838
1839 // 4. PROCESS TAXONOMIES IN CHUNKS
1840 $taxChunks = array_chunk($property['taxonomies'], 5, true);
1841 foreach ($taxChunks as $taxChunk) {
1842 foreach ($taxChunk as $taxonomy => $term) {
1843 if (isset($taxonomy_overrides[$taxonomy])) {
1844 $taxonomy = $taxonomy_overrides[$taxonomy];
1845 }
1846 wp_cache_delete("{$taxonomy}_term_counts", 'counts');
1847 $this->mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $term);
1848 $propertyHistory[] = 'Updated Taxonomy ' . $taxonomy . ' with terms ' . wp_json_encode($term);
1849
1850 // Memory cleanup after each taxonomy
1851 wp_cache_delete('term_meta', 'terms');
1852 wp_cache_delete($taxonomy, 'terms');
1853 }
1854
1855 // 5. FORCE GC AFTER EACH CHUNK
1856 gc_collect_cycles();
1857 }
1858
1859 // Restore term filter and clean up
1860 add_filter('get_term_metadata', 'lazyload_term_meta', 10, 2);
1861 delete_option('category_children');
1862
1863 // Re-enable term counting
1864 wp_defer_term_counting(false);
1865
1866 $memAfterTax = memory_get_usage(true);
1867 // " MB, Total Diff: " . round(($memAfterTax - $memBeforeTax) / 1048576, 2) . " MB");
1868 }
1869
1870 // 6. FLUSH SPECIFIC CACHES INSTEAD OF ALL
1871 // More targeted than wp_cache_flush()
1872 wp_cache_delete('terms', 'terms');
1873 wp_cache_delete('term_meta', 'terms');
1874 wp_cache_delete("post_meta_{$propertyId}", 'post_meta');
1875 wp_cache_delete($propertyId, 'posts');
1876
1877 // Prepare meta data
1878 $property = $this->mlsimportSaasPrepareMetaForProperty($property);
1879
1880 // 7. BATCH META UPDATES
1881 if (isset($property['meta']) && is_array($property['meta'])) {
1882 $memBeforeMeta = memory_get_usage(true);
1883 $metaCount = count($property['meta']);
1884
1885 // Use direct SQL for batch meta updates if many fields
1886 if ($metaCount > 0 && method_exists($wpdb, 'prepare')) {
1887 $meta_values = [];
1888 foreach ($property['meta'] as $metaName => $metaValue) {
1889 if (is_array($metaValue)) {
1890 $metaValue = implode(', ', array_map('trim', $metaValue));
1891 } else {
1892 $metaValue = preg_replace('/\s*,\s*/', ', ', trim($metaValue));
1893 }
1894
1895 // Build history separately
1896 $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1897
1898 // First delete existing
1899 $wpdb->delete(
1900 $wpdb->postmeta,
1901 ['post_id' => $propertyId, 'meta_key' => $metaName],
1902 ['%d', '%s']
1903 );
1904
1905 // Collect for batch insert
1906 $meta_values[] = $wpdb->prepare(
1907 "(%d, %s, %s)",
1908 $propertyId,
1909 $metaName,
1910 $metaValue
1911 );
1912 }
1913
1914 // Batch insert all meta at once
1915 if (!empty($meta_values)) {
1916 $wpdb->query("INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES " .
1917 implode(", ", $meta_values));
1918 }
1919 } else {
1920 // Dead code - left intentianaly
1921 // Standard approach for fewer meta fields
1922 foreach ($property['meta'] as $metaName => $metaValue) {
1923 if (is_array($metaValue)) {
1924 $metaValue = implode(', ', array_map('trim', $metaValue));
1925 } else {
1926 $metaValue = preg_replace('/\s*,\s*/', ', ', trim($metaValue));
1927 }
1928 update_post_meta($propertyId, $metaName, $metaValue);
1929 $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1930 }
1931 }
1932
1933 $memAfterMeta = memory_get_usage(true);
1934 // " MB, Diff: " . round(($memAfterMeta - $memBeforeMeta) / 1048576, 2) . " MB");
1935 }
1936
1937 // Extra meta processing
1938 $extraMetaResult = $mlsimport->admin->env_data->mlsimportSaasSetExtraMeta($propertyId, $property);
1939 if (isset($extraMetaResult['property_history'])) {
1940 $propertyHistory = array_merge($propertyHistory, (array)$extraMetaResult['property_history']);
1941 }
1942
1943 // 8. PROCESS MEDIA IN CHUNKS
1944 $memBeforeMedia = memory_get_usage(true);
1945
1946
1947 if (isset($property['Media']) && is_array($property['Media'])) {
1948 $media_attachments=array();
1949
1950 $mediaCount = count($property['Media']);
1951
1952 // Detect if media has changed for existing properties
1953 $shouldRefreshMedia = false;
1954 if ($isInsert === 'no') {
1955 $shouldRefreshMedia = $this->hasMediaChanged($propertyId, $property['Media']);
1956 if ($shouldRefreshMedia) {
1957 $this->writeImportLogs('Media changed for property ' . $propertyId . ', refreshing ' . $mediaCount . ' images', $tipImport);
1958 $this->deleteExistingMlsAttachments($propertyId);
1959 } else {
1960 $this->writeImportLogs('Media unchanged for property ' . $propertyId . ', skipping image refresh', $tipImport);
1961 }
1962 }
1963
1964 // Sort media by Order field if it exists
1965 if (isset($property['Media'][0]['Order'])) {
1966 $order = array_column($property['Media'], 'Order');
1967 array_multisort($order, SORT_ASC, $property['Media']);
1968 }
1969
1970 // Process in chunks of 5
1971 $mediaChunks = array_chunk($property['Media'], 5,true);
1972 $mediaHistoryParts = [];
1973
1974 // Clear original array to free memory
1975 $originalMedia = $property['Media'];
1976
1977 // Find featured image in single loop
1978 $featuredImageKey = null;
1979 $orderOneKey = null;
1980
1981 // First priority: Look for PreferredPhotoYN = 1
1982 foreach ($property['Media'] as $key => $mediaItem) {
1983 // Priority 1: PreferredPhotoYN = 1 (immediate selection)
1984 if (isset($mediaItem['PreferredPhotoYN']) && $mediaItem['PreferredPhotoYN'] == 1) {
1985 $featuredImageKey = $key;
1986 break;
1987 }
1988
1989
1990 // Priority 2: Store Order = 1 key for potential use
1991 if ($orderOneKey === null && isset($mediaItem['Order']) && $mediaItem['Order'] == 1) {
1992 $orderOneKey = $key;
1993 }
1994
1995 }
1996
1997 if ($featuredImageKey === null && $orderOneKey !== null) {
1998 $featuredImageKey = $orderOneKey;
1999 }
2000
2001 // Use Order = 1 image if no preferred image was found
2002 if ($featuredImageKey === null && $orderOneKey !== null) {
2003 $featuredImageKey = $orderOneKey;
2004 }
2005
2006 // Priority 3: Use first image if nothing else found
2007 if ($featuredImageKey === null && !empty($property['Media'])) {
2008 $featuredImageKey = 0;
2009 }
2010
2011
2012 unset($property['Media']);
2013
2014 if ($isInsert !== 'no' || $shouldRefreshMedia) {
2015 delete_post_meta($propertyId, 'fave_property_images');
2016 delete_post_meta($propertyId, 'REAL_HOMES_property_images');
2017 delete_post_meta($propertyId, 'wpestate_property_gallery');
2018 }
2019
2020
2021 foreach ($mediaChunks as $index => $mediaChunk) {
2022 $media_attachments = $this->mlsimportSassAttachMediaToPost($propertyId, $mediaChunk, $isInsert,$media_attachments,$featuredImageKey, $shouldRefreshMedia);
2023 // $mediaHistoryParts[] = $chunkHistory;
2024
2025 // Free memory
2026 unset($mediaChunk);
2027 //unset($chunkHistory);
2028 gc_collect_cycles();
2029
2030 // Incremental progress report
2031 }
2032
2033
2034 // Only rewrite the gallery when we actually (re)built the attachment list
2035 // (insert or media refresh). On the unchanged-media path $media_attachments
2036 // is empty, and overwriting would wipe the existing gallery.
2037 if ($isInsert !== 'no' || $shouldRefreshMedia) {
2038 $mlsimport->admin->env_data->enviroment_image_save_gallery($propertyId, $media_attachments);
2039 }
2040
2041 // Combine all chunks
2042 // $mediaHistory = implode('</br>', $mediaHistoryParts);
2043 // $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
2044
2045 // Clean up
2046 unset($mediaChunks);
2047 unset($mediaHistoryParts);
2048 unset($mediaHistory);
2049 unset($originalMedia);
2050 } else {
2051 $mediaHistory = $this->mlsimportSassAttachMediaToPost($propertyId, $property['Media'] ?? [], $isInsert,$featuredImageKey);
2052 $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
2053 }
2054
2055
2056 $memAfterMedia = memory_get_usage(true);
2057 // " MB, Diff: " . round(($memAfterMedia - $memBeforeMedia) / 1048576, 2) . " MB");
2058
2059 // Update title
2060 $newTitle = $this->mlsimportSaasUpdatePropertyTitle($propertyId, $itemIdArray['item_id'], $property);
2061 $propertyHistory[] = 'Updated title to ' . $newTitle . '</br>';
2062
2063 // Correlation update
2064 $mlsimport->admin->env_data->correlationUpdateAfter($isInsert, $propertyId, [], $newAgent);
2065
2066 // 9. COMMIT TRANSACTION
2067 if (method_exists($wpdb, 'query')) {
2068 $wpdb->query('COMMIT');
2069 $wpdb->query('SET autocommit = 1');
2070 }
2071
2072 // Save property history - using direct SQL if history is large
2073 if (!empty($propertyHistory)) {
2074 if (intval(get_option('mlsimport-disable-history', 1)) === 1) {
2075 $propertyHistory[] = '---------------------------------------------------------------</br>';
2076 $propertyHistory = implode('</br>', $propertyHistory);
2077
2078 // 10. USE DIRECT SQL FOR LARGE HISTORY
2079 if (strlen($propertyHistory) > 10000 && method_exists($wpdb, 'update')) {
2080 $wpdb->update(
2081 $wpdb->postmeta,
2082 ['meta_value' => $propertyHistory],
2083 ['post_id' => $propertyId, 'meta_key' => 'mlsimport_property_history'],
2084 ['%s'],
2085 ['%d', '%s']
2086 );
2087 } else {
2088 update_post_meta($propertyId, 'mlsimport_property_history', $propertyHistory);
2089 }
2090 }
2091 }
2092
2093 // 11. RESTORE ACTIONS
2094 if (!empty($suspended_actions)) {
2095 foreach ($suspended_actions as $action => $true) {
2096 add_action($action, '_wp_action_exists_' . $action);
2097 remove_action($action, '_wp_action_exists_' . $action);
2098 }
2099 }
2100
2101 // 12. FINAL CLEANUP
2102 $property = null;
2103 $propertyHistory = null;
2104 wp_cache_flush();
2105 gc_collect_cycles();
2106
2107 // Final memory stats
2108 $memEnd = memory_get_usage(true);
2109
2110 return $newTitle;
2111 }
2112
2113
2114 /**
2115 * Check if incoming MLS media differs from existing MLS-imported attachments.
2116 *
2117 * Compares incoming MediaURL values against the GUIDs of existing attachments
2118 * that have the is_mlsimport meta flag. Uses ID-only queries for memory efficiency.
2119 *
2120 * @param int $propertyId The property post ID.
2121 * @param array $incomingMedia Array of media items, each with a 'MediaURL' key.
2122 * @return bool True if images need refresh, false if unchanged.
2123 */
2124 private function hasMediaChanged($propertyId, $incomingMedia) {
2125 $existing = get_posts([
2126 'post_type' => 'attachment',
2127 'post_parent' => $propertyId,
2128 'post_status' => 'inherit',
2129 'meta_key' => 'is_mlsimport',
2130 'meta_value' => 1,
2131 'fields' => 'ids',
2132 'numberposts' => -1,
2133 ]);
2134
2135 $existingUrls = array_map(function ($id) {
2136 return get_post_field('guid', $id);
2137 }, $existing);
2138
2139 $incomingUrls = array_filter(array_column($incomingMedia, 'MediaURL'));
2140
2141 sort($existingUrls);
2142 sort($incomingUrls);
2143
2144 return $existingUrls !== $incomingUrls;
2145 }
2146
2147
2148 /**
2149 * Delete all MLS-imported attachments for a property.
2150 *
2151 * Only deletes attachments that have the is_mlsimport post meta set to 1.
2152 * Manually uploaded attachments are preserved.
2153 *
2154 * @param int $propertyId The property post ID.
2155 */
2156 private function deleteExistingMlsAttachments($propertyId) {
2157 $mlsAttachments = get_posts([
2158 'post_type' => 'attachment',
2159 'post_parent' => $propertyId,
2160 'post_status' => 'inherit',
2161 'meta_key' => 'is_mlsimport',
2162 'meta_value' => 1,
2163 'fields' => 'ids',
2164 'numberposts' => -1,
2165 ]);
2166
2167 foreach ($mlsAttachments as $attachId) {
2168 wp_delete_post($attachId, true);
2169 }
2170 }
2171
2172
2173
2174 }
2175