PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.0.6
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.0.6
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 7.0.6, at includes/ThemeImport.php

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