PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2.1
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2.1
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
← All changes | includes/ThemeImport.php +353 -1637 6.3.87.2.1 View file →
@@ -1,21 +1,55 @@
1 1 <?php
2 +/**
3 + * ThemeImport — SaaS API client and Stored Listing Write compatibility edge.
4 + *
5 + * The class retains the SaaS request helpers, batch compatibility entry points,
6 + * and reconciliation utilities used by older callers. Per-listing Stored mode
7 + * persistence is deliberately narrow: mlsimportSaasPrepareToImportPerItem()
8 + * translates legacy task option names and delegates once to the injected
9 + * Mlsimport_Stored_Listing_Write module.
10 + *
11 + * Listing status, post/meta/taxonomy writes, field normalization, title, media,
12 + * activity, and error outcomes no longer live in this compatibility class.
13 + *
14 + * @package MLSImport
15 + */
2 16 if ( ! defined( 'ABSPATH' ) ) {
3 17 exit; // Exit if accessed directly
4 18 }
5 19
6 20 /**
7 - * Description of ThemeImport
8 - *
9 - * @class ThemeImport
21 + * Expose legacy API/batch methods around the explicit listing-write module.
10 22 */
11 23 class ThemeImport {
12 24
13 25
26 + // Active theme adapter / identifier (set by callers).
14 27 public $theme;
28 + // Plugin slug/name carried for logging and context.
15 29 public $plugin_name;
30 + // Environment adapter instance (theme-specific meta mapping).
16 31 public $enviroment;
32 + // Cached encoded credential/config values.
17 33 public $encoded_values;
34 +
35 + /** @var object|null Injected Stored Listing Write module. */
36 + private $stored_listing_write;
37 +
38 + /**
39 + * Configure the API client and optional Stored mode write boundary.
40 + *
41 + * Most ThemeImport instances only call the SaaS API and therefore need no
42 + * writer. Admin composition injects the writer once; listing calls then
43 + * delegate without reading the global admin object or theme adapter.
44 + *
45 + * @param string $plugin_name Plugin slug used by legacy callers.
46 + * @param object|null $stored_listing_write Single listing write module.
47 + */
48 + public function __construct( $plugin_name = '', $stored_listing_write = null ) {
49 + $this->plugin_name = (string) $plugin_name;
50 + $this->stored_listing_write = $stored_listing_write;
51 + }
18 52
19 53
20 54 /**
21 55 * Api Request to MLSimport API using CURL
@@ -31,17 +65,22 @@
31 65
32 66 global $mlsimport;
33 67
34 68 // Skip validation for token requests
69 + // (the token call is what mints the credential, so it can't require one).
35 70 if ($method !== 'token') {
71 + // Ensure a live JWT before any non-token call; bail out with a message on failure.
36 72 if (!self::validateAndRefreshToken()) {
37 73 return 'Token validation failed';
38 74 }
39 75 }
40 -
76 +
77 + // Build the full endpoint URL from the SaaS base + method path.
41 78 $url = MLSIMPORT_API_URL . $method;
79 + // Default headers for the token request (plain text body).
42 80 $headers = ['Content-Type' => 'text/plain'];
43 81
82 + // For authenticated calls, swap to JSON + Bearer token headers.
44 83 if ($method !== 'token') {
45 84 $token = self::getApiToken();
46 85 $headers = [
47 86 'Content-Type' => 'application/json',
@@ -47,9 +86,10 @@
47 86 'Content-Type' => 'application/json',
48 87 'Authorization' => 'Bearer '.$token,
49 88 ];
50 89 }
51 -
90 +
91 + // Assemble the wp_remote_* argument array (long timeout for large payloads).
52 92 $args = [
53 93 'method' => $type,
54 94 'headers' => $headers,
55 95 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null,
@@ -58,17 +98,28 @@
58 98 'httpversion' => '1.1',
59 99 'blocking' => true,
60 100 'user-agent' => $_SERVER['HTTP_USER_AGENT'],
61 101 ];
62 -
63 102
103 +
104 + // Dispatch as GET or POST depending on $type.
64 105 $response = $type === 'GET' ? wp_remote_get($url, $args) : wp_remote_post($url, $args);
65 106
107 + // #208 recovery: same rule as globalApiRequestSaas() — one refresh and
108 + // one retry when the server rejects the Bearer token mid-flight.
109 + if ( 'token' !== $method
110 + && ! is_wp_error( $response )
111 + && 401 === intval( $response['response']['code'] ?? 0 )
112 + && self::refreshToken() ) {
113 + $args['headers']['Authorization'] = 'Bearer ' . self::getApiToken();
114 + $response = $type === 'GET' ? wp_remote_get( $url, $args ) : wp_remote_post( $url, $args );
115 + }
66 116
67 -
117 + // Transport-level failure: return the WP_Error message string.
68 118 if (is_wp_error($response)) {
69 119 return $response->get_error_message();
70 120 } else {
121 + // Otherwise decode the JSON body and return the array (or a decode-error string).
71 122 $body = wp_remote_retrieve_body($response);
72 123
73 124 $toReturn = json_decode($body, true);
74 125 if (json_last_error() !== JSON_ERROR_NONE) {
@@ -133,12 +184,26 @@
133 184 return true;
134 185 }
135 186
136 187
188 + /**
189 + * Blocking request to the SaaS API returning the decoded response.
190 + *
191 + * Validates/refreshes the JWT for anything other than the public 'token'
192 + * and 'mls' methods, always POSTs the JSON body (regardless of $type),
193 + * and normalises errors into a ['success' => false, ...] array. On HTTP 200
194 + * the raw decoded body is returned as-is.
195 + *
196 + * @param string $method The API method/path to call.
197 + * @param array $valuesArray The request body data.
198 + * @param string $type The nominal request type (default 'GET').
199 + * @return mixed Decoded response array, or an error descriptor array.
200 + */
137 201 public static function globalApiRequestSaas($method, $valuesArray, $type = 'GET') {
138 202 global $mlsimport;
139 203 // Skip validation for token and mls requests
140 204 if ($method !== 'token' && $method !== 'mls') {
205 + // Guarantee a valid token; otherwise return a failure descriptor.
141 206 if (!self::validateAndRefreshToken()) {
142 207 return [
143 208 'success' => false,
144 209 'error_message' => 'Token validation failed'
@@ -145,11 +210,13 @@
145 210 ];
146 211 }
147 212 }
148 213
149 -
214 +
215 + // Full endpoint URL.
150 216 $url = MLSIMPORT_API_URL . $method;
151 217
218 + // Attach Bearer auth headers for authenticated methods only.
152 219 $headers = [];
153 220 if ($method !== 'token' && $method !== 'mls') {
154 221 $token = self::getApiToken();
155 222 $headers = [
@@ -158,8 +225,9 @@
158 225 ];
159 226 }
160 227
161 228
229 + // Request arguments (note: always dispatched via wp_remote_post below).
162 230 $args = [
163 231 'method' => $type,
164 232 'timeout' => 45,
165 233 'redirection' => 5,
@@ -168,12 +236,25 @@
168 236 'headers' => $headers,
169 237 'cookies' => [],
170 238 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null,
171 239 ];
240 + // Always POST (even for logical GETs) — the SaaS expects a JSON body.
172 241 $response = wp_remote_post($url, $args);
173 242
243 + // #208 recovery: a 401 on an authenticated call means the server
244 + // rejected the Bearer token even though the stored expiry looked
245 + // valid (revoked server-side, clock skew). Refresh once and retry
246 + // the same request once; a second 401 falls through to the normal
247 + // error path below. Token/mls calls carry no Bearer, so no retry.
248 + if ( 'token' !== $method && 'mls' !== $method
249 + && ! is_wp_error( $response )
250 + && 401 === intval( $response['response']['code'] ?? 0 )
251 + && self::refreshToken() ) {
252 + $args['headers']['Authorization'] = 'Bearer ' . self::getApiToken();
253 + $response = wp_remote_post( $url, $args );
254 + }
174 255
175 -
256 + // Transport error → structured failure with WP error code/message.
176 257 if (is_wp_error($response)) {
177 258 return [
178 259 'success' => false,
179 260 'error_code' => $response->get_error_code(),
@@ -180,31 +261,37 @@
180 261 'error_message' => esc_html($response->get_error_message())
181 262 ];
182 263 }
183 264
265 + // Extract HTTP status code and raw body.
184 266 $status_code = isset($response['response']['code']) ? intval($response['response']['code']) : 0;
185 267 $body = wp_remote_retrieve_body($response);
186 268
269 + // 200 → return the decoded payload untouched.
187 270 if (200 === $status_code) {
188 271 $receivedData = json_decode($body, true);
189 272 return $receivedData;
190 273 }
191 274
275 + // Non-200: try to pull a human-readable error out of the JSON body.
192 276 $error_message = 'Unknown error';
193 277 $error_code = $status_code;
194 278
195 279 $decoded_body = json_decode($body, true);
196 280 if (json_last_error() === JSON_ERROR_NONE && is_array($decoded_body)) {
281 + // Preferred shape: { error: { message, code } }.
197 282 if (isset($decoded_body['error']['message'])) {
198 283 $error_message = $decoded_body['error']['message'];
199 284 if (isset($decoded_body['error']['code'])) {
200 285 $error_code = $decoded_body['error']['code'];
201 286 }
287 + // Fallback shape: { message }.
202 288 } elseif (isset($decoded_body['message'])) {
203 289 $error_message = $decoded_body['message'];
204 290 }
205 291 }
206 292
293 + // Return the normalised error descriptor (the exit() below is unreachable).
207 294 return [
208 295 'success' => false,
209 296 'error_code' => $error_code,
210 297 'error_message' => esc_html($error_message),
@@ -227,34 +314,103 @@
227 314 // Get stored expiry timestamp
228 315 $token_expiry = get_option('mlsimport_token_expiry', 0);
229 316 $current_time = time();
230 317
231 - // Check if token is expired
318 + // Check if token is expired (now at/after the stored expiry).
232 319 if ($current_time >= $token_expiry) {
233 320 // Token expired, refresh it
234 321 $refresh_result = self::refreshToken();
235 -
322 +
323 + // Propagate refresh failure to the caller.
236 324 if (!$refresh_result) {
237 325 return false;
238 326 }
239 327 }
240 -
328 +
329 + // Token is present and not past expiry.
241 330 return true;
242 331 }
243 332
333 + /**
334 + * Record the SaaS connection-health state (#208).
335 + *
336 + * Stores array{status, since} in the mlsimport_connection_health option:
337 + * 'healthy', 'credentials_invalid' (server rejected the stored account),
338 + * 'no_subscription' (password accepted, account not active — #322) or
339 + * 'credentials_missing' (nothing configured). Transient failures such
340 + * as network timeouts never call this, so a working state is not lost to
341 + * a hiccup. Re-recording an unchanged status is skipped so 'since' keeps
342 + * pointing at when the state actually began.
343 + *
344 + * @param string $status New health status keyword.
345 + * @return void
346 + */
347 + private static function setConnectionHealth( $status ) {
348 + $health = get_option( 'mlsimport_connection_health', array() );
349 + if ( is_array( $health ) && ( $health['status'] ?? '' ) === $status ) {
350 + return;
351 + }
352 + update_option(
353 + 'mlsimport_connection_health',
354 + array(
355 + 'status' => $status,
356 + 'since' => time(),
357 + )
358 + );
359 +
360 + // #208: a state CHANGE is the incident boundary — broken credentials
361 + // open the connection incident, a working refresh resolves it. The
362 + // alerts module dedups, so this cannot spam the SaaS.
363 + if ( 'healthy' === $status ) {
364 + if ( function_exists( 'mlsimport_alert_resolve' ) ) {
365 + mlsimport_alert_resolve( 'connection:credentials' );
366 + }
367 + } elseif ( function_exists( 'mlsimport_alert_open' ) ) {
368 + mlsimport_alert_open( 'connection:credentials', 'connection_broken', array( 'status' => $status ) );
369 + }
370 + }
371 +
372 + /**
373 + * Request a fresh JWT from the SaaS 'token' endpoint and cache it.
374 + *
375 + * Reads the stored username/password, POSTs them, and on success stores the
376 + * token in a transient plus the expiry timestamp in an option. Bumps the
377 + * 'token_failures' telemetry counter on every failure path — WITHOUT a
378 + * connection id (#283): the SaaS JWT is account-level, shared by every
379 + * connection, so its failures belong to no single MLS and count only in
380 + * the global bucket.
381 + *
382 + * @return bool True on successful refresh, false otherwise.
383 + */
244 384 private static function refreshToken() {
245 385 global $mlsimport;
246 386
247 387 // Get credentials for token request
248 388 $options = get_option('mlsimport_admin_options');
389 + // Pull the SaaS account credentials out of the plugin options.
249 390 $username = isset($options['mlsimport_username']) ? $options['mlsimport_username'] : '';
250 391 $password = isset($options['mlsimport_password']) ? $options['mlsimport_password'] : '';
251 -
392 +
393 + // No credentials configured → cannot refresh.
252 394 if (empty($username) || empty($password)) {
253 395 mlsimport_telemetry_bump( 'token_failures' );
396 + self::setConnectionHealth( 'credentials_missing' );
254 397 return false;
255 398 }
256 -
399 +
400 + // #208 single-flight: only one process may refresh at a time.
401 + // add_option() is a plain INSERT, so a concurrent process loses the
402 + // race and backs off without firing a second token request. A lock
403 + // older than 60 seconds belongs to a crashed owner (the token request
404 + // itself times out at 45) and is taken over instead.
405 + if ( ! add_option( 'mlsimport_token_refresh_lock', time(), '', 'no' ) ) {
406 + $lock_held_since = intval( get_option( 'mlsimport_token_refresh_lock', 0 ) );
407 + if ( time() - $lock_held_since < 60 ) {
408 + return false;
409 + }
410 + update_option( 'mlsimport_token_refresh_lock', time() );
411 + }
412 +
257 413 // Prepare token request
258 414 $url = MLSIMPORT_API_URL . 'token';
259 415 $body = wp_json_encode(array(
260 416 'username' => $username,
@@ -271,214 +427,70 @@
271 427 );
272 428
273 429 // Make token request
274 430 $response = wp_remote_post($url, $args);
275 -
431 +
432 + // Transport failure → count and abort (lock released for the next try).
276 433 if (is_wp_error($response)) {
277 434 mlsimport_telemetry_bump( 'token_failures' );
435 + delete_option( 'mlsimport_token_refresh_lock' );
278 436 return false;
279 437 }
280 438
439 + // Decode the JSON token response.
281 440 $body = wp_remote_retrieve_body($response);
282 441 $data = json_decode($body, true);
442 + $code = intval( $response['response']['code'] ?? 0 );
283 443
444 + // Reject any response missing success/token/expires.
284 445 if (!isset($data['success']) || !$data['success'] || !isset($data['token']) || !isset($data['expires'])) {
285 446 mlsimport_telemetry_bump( 'token_failures' );
447 + delete_option( 'mlsimport_token_refresh_lock' );
448 + // The server answered and said no → terminal until the user acts.
449 + // HTTP 403 means the password was right but the account has no
450 + // active subscription (#322); anything else is bad credentials.
451 + // A malformed/partial body is a server hiccup instead and leaves
452 + // health untouched.
453 + if ( is_array( $data ) && array_key_exists( 'success', $data ) && ! $data['success'] ) {
454 + self::setConnectionHealth( 403 === $code ? 'no_subscription' : 'credentials_invalid' );
455 + // Same verdict, remembered for the "not connected" screens.
456 + mlsimport_account_status_record( array( 'success' => false, 'error_code' => $code ) );
457 + }
286 458 return false;
287 459 }
460 +
461 + // A working login wipes any remembered failure reason (#322).
462 + mlsimport_account_status_record( $data );
288 463
289 464 // Store new token and expiry
290 465 //$mlsimport->admin->mlsimport_saas_store_mls_api_token_transient($data['token']);
291 -
466 +
467 + // Cache the token in a transient sized to its remaining lifetime.
292 468 $expires_in = $data['expires'] - time();
293 469 set_transient('mlsimport_saas_token', $data['token'], $expires_in);
294 470
471 + // Persist the absolute expiry so validateAndRefreshToken() can compare against it.
295 472 update_option('mlsimport_token_expiry', intval($data['expires']));
296 473
297 474 // First successful SaaS account connection (lifecycle telemetry).
298 475 mlsimport_telemetry_set_once( 'account_connected_at', time() );
299 476
477 + // Refresh finished — release the single-flight lock.
478 + delete_option( 'mlsimport_token_refresh_lock' );
479 +
480 + // A minted token proves the account works → back to healthy.
481 + self::setConnectionHealth( 'healthy' );
482 +
300 483 return true;
301 484 }
302 485
303 486
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 487
327 488
328 489
329 - // Only keep essential data in memory, discard the rest
330 - foreach ($readyToParseArray['data'] as $key => $property) {
331 -
332 490
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 491
346 492
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 493
482 494
483 495
484 496 /**
@@ -490,18 +502,8 @@
490 502 private function writeImportLogs($logs, $type) {
491 503 mlsimport_saas_single_write_import_custom_logs($logs, $type);
492 504 }
493 505
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 506
505 507
506 508
507 509
@@ -506,61 +508,15 @@
506 508
507 509
508 510
509 511
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 512
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 513
532 - if ($count === 0) {
533 - $this->writeImportLogs('[Memory] No data to parse in batch ' . $batchKey, 'cron');
534 - return;
535 - }
536 514
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 515
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 516
545 - // Main per-property import function (handles mapping/import/update)
546 - $this->mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, 'cron', $mlsimportItemOptionData);
547 517
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 518
559 - // Housekeeping
560 - unset($readyToParseArray, $mlsimportItemOptionData);
561 - gc_collect_cycles();
562 - }
563 519
564 520
565 521
566 522
@@ -570,440 +526,35 @@
570 526
571 527
572 528
573 529
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 530
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 531
607 532
608 533
609 534
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 535
632 536
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 537
846 538
847 539
848 540
849 541
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 542
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 543
886 - if (is_array($postal_code)) {
887 - $postal_code = reset($postal_code);
888 - }
889 - $postal_code = trim((string) $postal_code);
890 544
891 - if ('' !== $postal_code && empty($property['extra_meta']['PostalCode'])) {
892 - $property['extra_meta']['PostalCode'] = $postal_code;
893 - }
894 545
895 - return $property;
896 - }
897 -
898 -
899 -
900 -
901 546
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 547
912 - $mediaHistory = [];
913 548
914 - if ($isInsert === 'no' && !$shouldRefreshMedia) {
915 - $mediaHistory[] = 'Media - We have edit - images are not replaced';
916 - return $media_attachments;
917 - }
918 549
919 - global $mlsimport;
920 - include_once ABSPATH . 'wp-admin/includes/image.php';
921 - $hasFeatured = false;
922 550
923 -
924 551
925 552
926 - add_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']);
927 553
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 554
935 - if ( empty( $image['MediaURL'] ) ) {
936 - continue;
937 - }
938 555
939 -
940 -
941 - if (isset($image['MediaURL'])) {
942 - $file = $image['MediaURL'];
943 - $attachment = [
944 - 'guid' => $file,
945 - 'post_status' => 'inherit',
946 - 'post_content' => '',
947 - 'post_parent' => $propertyId,
948 - 'post_mime_type' => $image['MimeType'] ?? 'image/jpeg',
949 - 'post_title' => $image['MediaKey'] ?? '',
950 - ];
951 -
952 -
953 - $attachId = wp_insert_attachment($attachment, $file);
954 - // wp_insert_attachment() returns 0 (int) on failure, not a
955 - // WP_Error, so is_wp_error() alone lets a failed insert through
956 - // and records a 0 — which poisons the gallery and makes the
957 - // featured-image fallback set_post_thumbnail($id, 0) a no-op.
958 - if (is_wp_error($attachId) || ! $attachId) {
959 - } else {
960 - $mediaHistory[] = 'Media - Added ' . $file . ' as attachment ' . $attachId;
961 - $media_attachments[]=$attachId;
962 -
963 -
964 - $mlsimport->admin->env_data->enviroment_image_save($propertyId, $attachId);
965 - update_post_meta($attachId, 'is_mlsimport', 1);
966 -
967 - if ($key===$featuredImageKey){
968 -
969 -
970 - set_post_thumbnail($propertyId, $attachId);
971 -
972 - } else {
973 - }
974 - }
975 - } else {
976 - }
977 - }
978 - } else {
979 - $mediaHistory[] = 'Media data is blank - there are no images';
980 - }
981 -
982 - remove_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']);
983 -
984 - return $media_attachments;
985 - //return implode('</br>', $mediaHistory);
986 - }
987 -
988 -
989 556 /**
990 - * Unset image sizes
991 - *
992 - * @param array $sizes The sizes to unset.
993 - * @return array The modified sizes array.
994 - */
995 - public function wpcUnsetImageSizes($sizes) {
996 - return [];
997 - }
998 -
999 -
1000 -
1001 -
1002 -
1003 -
1004 -
1005 - /**
1006 557 * Return user option
1007 558 *
1008 559 * @param int $selected The selected user ID.
1009 560 * @return string The HTML option elements for users.
@@ -1009,11 +560,13 @@
1009 560 * @return string The HTML option elements for users.
1010 561 */
1011 562 public function mlsimportSaasThemeImportSelectUser($selected) {
1012 563 $userOptions = '';
564 + // Fetch all users to build a <select> of possible property authors.
1013 565 $blogusers = get_users(['blog_id' => 1, 'orderby' => 'nicename']);
1014 566 foreach ($blogusers as $user) {
1015 567 $userOptions .= '<option value="' . esc_attr($user->ID) . '"';
568 + // Pre-select the currently chosen user.
1016 569 if ($user->ID == $selected) {
1017 570 $userOptions .= ' selected="selected"';
1018 571 }
1019 572 $userOptions .= '>' . esc_html($user->user_login) . '</option>';
@@ -1034,8 +587,9 @@
1034 587 * @return string The HTML option elements for agents.
1035 588 */
1036 589 public function mlsimportSaasThemeImportSelectAgent($selected) {
1037 590 global $mlsimport;
591 + // Query up to 150 published agents of the theme's agent post type.
1038 592 $args = [
1039 593 'post_type' => $mlsimport->admin->env_data->get_agent_post_type(),
1040 594 'post_status' => 'publish',
1041 595 'posts_per_page' => 150,
@@ -1041,15 +595,18 @@
1041 595 'posts_per_page' => 150,
1042 596 ];
1043 597
1044 598 $agentSelection = new WP_Query($args);
599 + // Start with a blank option (no agent).
1045 600 $agentOptions = '<option value=""></option>';
1046 601
602 + // Build one <option> per agent post.
1047 603 while ($agentSelection->have_posts()) {
1048 604 $agentSelection->the_post();
1049 605 $agentId = get_the_ID();
1050 606
1051 607 $agentOptions .= '<option value="' . esc_attr($agentId) . '"';
608 + // Pre-select the currently chosen agent.
1052 609 if ($agentId == $selected) {
1053 610 $agentOptions .= ' selected="selected"';
1054 611 }
1055 612 $agentOptions .= '>' . esc_html(get_the_title()) . '</option>';
@@ -1065,111 +622,19 @@
1065 622
1066 623
1067 624
1068 625
1069 - /**
1070 - * Delete property
1071 - *
1072 - * @param int $deleteId The ID of the property to delete.
1073 - * @param string $ListingKey The listing key of the property.
1074 - */
1075 - public function deleteProperty($deleteId, $ListingKey) {
1076 - if ($deleteId > 0) {
1077 - mlsimport_record_activity( 'deleted', $deleteId, get_post_meta($deleteId,'ListingKey',true), intval(get_post_meta($deleteId,'MLSimport_item_inserted',true)), 'import' );
1078 - $args = [
1079 - 'numberposts' => -1,
1080 - 'post_type' => 'attachment',
1081 - 'post_parent' => $deleteId,
1082 - 'post_status' => null,
1083 - 'orderby' => 'menu_order',
1084 - 'order' => 'ASC',
1085 - ];
1086 - $postAttachments = get_posts($args);
1087 626
1088 - foreach ($postAttachments as $attachment) {
1089 - wp_delete_post($attachment->ID);
1090 - }
1091 627
1092 - wp_delete_post($deleteId);
1093 - mlsimport_telemetry_bump( 'deleted' );
1094 - $logEntry = 'Property with id ' . $deleteId . ' and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
1095 - $this->writeImportLogs($logEntry, 'delete');
1096 - }
1097 - }
1098 628
1099 629
1100 630
1101 631
1102 - /**
1103 - * Return array with title items
1104 - *
1105 - * @param string $string The input string.
1106 - * @param string $start The start delimiter.
1107 - * @param string $end The end delimiter.
1108 - * @param bool $includeDelimiters Whether to include the delimiters in the result.
1109 - * @param int $offset The offset to start searching from.
1110 - * @return array The array of strings found between the delimiters.
1111 - */
1112 - public function strBetweenAll(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): array {
1113 - $strings = [];
1114 - $length = strlen($string);
1115 632
1116 - while ($offset < $length) {
1117 - $found = $this->strBetween($string, $start, $end, $includeDelimiters, $offset);
1118 - if ($found === null) {
1119 - break;
1120 - }
1121 633
1122 - $strings[] = $found;
1123 - $offset += strlen($includeDelimiters ? $found : $start . $found . $end); // move offset to the end of the newfound string
1124 - }
1125 634
1126 - return $strings;
1127 - }
1128 635
1129 636 /**
1130 - * Find string between delimiters
1131 - *
1132 - * @param string $string The input string.
1133 - * @param string $start The start delimiter.
1134 - * @param string $end The end delimiter.
1135 - * @param bool $includeDelimiters Whether to include the delimiters in the result.
1136 - * @param int $offset The offset to start searching from.
1137 - * @return string|null The string found between the delimiters, or null if not found.
1138 - */
1139 - public function strBetween(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): ?string {
1140 - if ($string === '' || $start === '' || $end === '') {
1141 - return null;
1142 - }
1143 -
1144 - $startLength = strlen($start);
1145 - $endLength = strlen($end);
1146 -
1147 - $startPos = strpos($string, $start, $offset);
1148 - if ($startPos === false) {
1149 - return null;
1150 - }
1151 -
1152 - $endPos = strpos($string, $end, $startPos + $startLength);
1153 - if ($endPos === false) {
1154 - return null;
1155 - }
1156 -
1157 - $length = $endPos - $startPos + ($includeDelimiters ? $endLength : -$startLength);
1158 - if (!$length) {
1159 - return '';
1160 - }
1161 -
1162 - $offset = $startPos + ($includeDelimiters ? 0 : $startLength);
1163 -
1164 - return substr($string, $offset, $length);
1165 - }
1166 -
1167 -
1168 -
1169 -
1170 -
1171 - /**
1172 637 * Delete property via SQL
1173 638 *
1174 639 * @param int $deleteId The ID of the property to delete.
1175 640 * @param string $ListingKey The listing key of the property.
@@ -1176,8 +641,9 @@
1176 641 */
1177 642 public function mlsimportSaasDeletePropertyViaMysql($deleteId, $ListingKey) {
1178 643 global $mlsimport;
1179 644
645 + // Resolve the post's type and the theme's expected property post type.
1180 646 $postType = get_post_type($deleteId);
1181 647 $propertyPostType = '';
1182 648 if (isset($mlsimport->admin->env_data) && method_exists($mlsimport->admin->env_data, 'get_property_post_type')) {
1183 649 $propertyPostType = $mlsimport->admin->env_data->get_property_post_type();
@@ -1182,10 +648,14 @@
1182 648 if (isset($mlsimport->admin->env_data) && method_exists($mlsimport->admin->env_data, 'get_property_post_type')) {
1183 649 $propertyPostType = $mlsimport->admin->env_data->get_property_post_type();
1184 650 }
1185 651
652 + // Only delete when the post is actually a property post type.
1186 653 if ($postType === $propertyPostType || in_array($postType, ['estate_property', 'property'])) {
1187 - // Delete attachments using WordPress functions so the files are removed as well
654 + // GitHub issue #287: capture the attachment IDs BEFORE any deletion
655 + // (they are found by post_parent, gone once the post row is), but do
656 + // NOT delete them yet. File deletion is the only irreversible step,
657 + // so it runs last — only after the post row is confirmed gone.
1188 658 $attachments = get_posts([
1189 659 'numberposts' => -1,
1190 660 'post_type' => 'attachment',
1191 661 'post_parent' => $deleteId,
@@ -1192,29 +662,74 @@
1192 662 'post_status' => null,
1193 663 'fields' => 'ids',
1194 664 ]);
1195 665
1196 - foreach ($attachments as $attachmentId) {
1197 - wp_delete_attachment($attachmentId, true);
1198 - }
1199 -
666 + // Capture the current status term names for the delete log.
1200 667 $termObjList = get_the_terms($deleteId, 'property_status');
1201 - $deleteIdStatus = join(', ', wp_list_pluck($termObjList, 'name'));
668 + $deleteIdStatus = is_array($termObjList) ? join(', ', wp_list_pluck($termObjList, 'name')) : '';
1202 669
1203 - $ListingKey = get_post_meta($deleteId, 'ListingKey', true);
670 + // Re-read the identity from protected meta (issue #286); an empty key
671 + // means a manually added listing.
672 + $ListingKey = get_post_meta($deleteId, '_mlsimport_listing_key', true);
1204 673 if ('' === $ListingKey) { // manually added listing
674 + // Never delete user-created listings; log and bail.
1205 675 $logEntry = 'User added listing with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' NOT DELETED' . PHP_EOL;
1206 676 $this->writeImportLogs($logEntry, 'delete');
1207 677 return;
1208 678 }
1209 679
1210 - mlsimport_record_activity( 'deleted', $deleteId, $ListingKey, intval(get_post_meta($deleteId,'MLSimport_item_inserted',true)), 'reconciliation' );
680 + // Capture the owning task before its meta row is deleted below; the
681 + // success activity entry still needs it afterward.
682 + $ownerTaskId = intval(get_post_meta($deleteId, 'MLSimport_item_inserted', true));
1211 683
684 + // Dedupe (issue #282): this raw-SQL path bypasses the WP delete
685 + // hooks, so capture the address group now (meta is gone after the
686 + // raw delete) and re-evaluate it after success — deleting a flagged
687 + // winner must promote its hidden loser.
688 + $dedupeAddressKey = (string) get_post_meta($deleteId, 'mlsimport_address_key', true);
689 + // Telemetry (#283): the deletion counts against the
690 + // listing's OWN connection — read the provenance stamp
691 + // (#278) before the raw delete wipes its meta.
692 + $provenanceMlsId = (int) get_post_meta($deleteId, 'mlsimport_mls_id', true);
693 +
1212 694 global $wpdb;
695 + // Raw SQL delete skips wp_delete_post (too slow), so nothing cleans the
696 + // property's term relationships, term counts or listings row. Do that
697 + // cleanup explicitly (SQL-first) before removing the post itself.
698 + // Standalone mode: purge the plugin's own term/listings relations first.
699 + if ( class_exists( 'Mlsimport_Standalone_Row' ) ) {
700 + Mlsimport_Standalone_Row::purge_post_relations( $deleteId );
701 + }
702 + // Raw delete of the post's meta, then the post and any remaining
703 + // non-attachment children. Attachment rows and meta must survive this
704 + // step so wp_delete_attachment() below can still remove their files.
1213 705 $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->postmeta WHERE `post_id` = %d", $deleteId));
1214 - $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE `post_parent` = %d OR `ID` = %d", $deleteId, $deleteId));
1215 - mlsimport_telemetry_bump( 'deleted' );
706 + $postsDeleted = $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE (`post_parent` = %d AND `post_type` != 'attachment') OR `ID` = %d", $deleteId, $deleteId));
1216 707
708 + // GitHub issue #287: a failed post delete must leave the listing fully
709 + // intact — no attachment deletion, no "deleted" history, no telemetry.
710 + if (false === $postsDeleted || $postsDeleted < 1) {
711 + $logEntry = 'MYSQL DELETE FAILED -> Property with id ' . $deleteId . ' (' . $postType . ') and ' . $ListingKey . ' was NOT deleted; attachments left untouched' . PHP_EOL;
712 + $this->writeImportLogs($logEntry, 'delete');
713 + return;
714 + }
715 +
716 + // The post row is durably gone; removing the now-orphaned attachments
717 + // (rows, meta, and files) can no longer strand a visible listing.
718 + foreach ($attachments as $attachmentId) {
719 + wp_delete_attachment($attachmentId, true);
720 + }
721 +
722 + // Dedupe (issue #282): the post row is durably gone — settle the
723 + // surviving copies of its address group (promote a hidden loser).
724 + if ('' !== $dedupeAddressKey && function_exists('mlsimport_dedupe_evaluate')) {
725 + mlsimport_dedupe_evaluate($dedupeAddressKey, (string) $postType);
726 + }
727 +
728 + // Record the deletion in the activity feed only after it happened.
729 + mlsimport_record_activity( 'deleted', $deleteId, $ListingKey, $ownerTaskId, 'reconciliation' );
730 + mlsimport_telemetry_bump( 'deleted', 1, $provenanceMlsId );
731 +
1217 732 $logEntry = 'MYSQL DELETE -> Property with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
1218 733 $this->writeImportLogs($logEntry, 'delete');
1219 734 }
1220 735 }
@@ -1227,262 +742,58 @@
1227 742
1228 743
1229 744
1230 745 /**
1231 - * Prepare to import per item
746 + * Delegate one incoming property to the explicit Stored Listing Write module.
1232 747 *
1233 - * @param array $property The property data.
1234 - * @param array $itemIdArray The item ID array.
1235 - * @param string $tipImport The import type.
1236 - * @param array $mlsimportItemOptionData The item option data.
748 + * ThemeImport translates the legacy Import Task option names once at this
749 + * compatibility edge. Listing decisions, common normalization, ordering,
750 + * persistence, media, activity, and terminal outcomes stay behind write().
751 + *
752 + * @param array<string, mixed> $property Raw RESO property.
753 + * @param array<string, mixed> $itemIdArray Import Task identity.
754 + * @param string $tipImport Manual or cron source.
755 + * @param array<string, mixed> $mlsimportItemOptionData Legacy task options.
756 + * @return array<string, mixed>|false Public write result, or false if unconfigured.
1237 757 */
1238 -public function mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, $tipImport, $mlsimportItemOptionData) {
1239 - // Pre-execution memory optimization
1240 - wp_cache_flush();
1241 - gc_collect_cycles();
1242 -
1243 - // Temporarily disable WordPress hooks that might add to memory usage
1244 - global $wp_filter;
1245 - $saved_filters = array();
1246 - if (isset($wp_filter['transition_post_status'])) {
1247 - $saved_filters['transition_post_status'] = $wp_filter['transition_post_status'];
1248 - unset($wp_filter['transition_post_status']);
758 +public function mlsimportSaasPrepareToImportPerItem( $property, $itemIdArray, $tipImport, $mlsimportItemOptionData ) {
759 + // A ThemeImport object used only for static SaaS/reconciliation helpers has
760 + // no writer. If a listing call reaches such an object, fail this item without
761 + // mutating WordPress; shared task execution will continue with the next one.
762 + if ( null === $this->stored_listing_write ) {
763 + $this->writeImportLogs(
764 + empty( $property['ListingKey'] )
765 + ? 'ERROR: No Listing Key ' . PHP_EOL
766 + : 'ERROR: Stored Listing Write is not configured.' . PHP_EOL,
767 + (string) $tipImport
768 + );
769 + return false;
1249 770 }
1250 - if (isset($wp_filter['save_post'])) {
1251 - $saved_filters['save_post'] = $wp_filter['save_post'];
1252 - $wp_filter['save_post'] = new WP_Hook();
1253 - }
1254 - set_time_limit(0);
1255 - global $mlsimport;
1256 771
1257 - // Log initial memory
1258 - $memStart = memory_get_usage(true);
1259 - $memStartMB = round($memStart / 1048576, 2);
772 + // Translate the shallow legacy option array into the stable module settings.
773 + // The listing's provenance (issue #278) is the task's OWN connection binding
774 + // (#277) read straight from post meta — deliberately NO current-connection
775 + // fallback on the write path (decision #266): an unbound task stamps 0
776 + // rather than silently adopting whichever connection is globally selected.
777 + $settings = array(
778 + 'task_id' => (int) ( $itemIdArray['item_id'] ?? 0 ),
779 + 'mls_id' => (int) get_post_meta( (int) ( $itemIdArray['item_id'] ?? 0 ), 'mlsimport_item_mls_id', true ),
780 + 'source' => (string) $tipImport,
781 + 'statuses' => is_array( $mlsimportItemOptionData['mlsimport_item_standardstatus'] ?? null )
782 + ? $mlsimportItemOptionData['mlsimport_item_standardstatus']
783 + : array(),
784 + 'user_id' => (int) ( $mlsimportItemOptionData['mlsimport_item_property_user'] ?? 0 ),
785 + 'assigned_agent_id' => (int) ( $mlsimportItemOptionData['mlsimport_item_agent'] ?? 0 ),
786 + 'use_mls_agent' => ! empty( $mlsimportItemOptionData['mlsimport_item_use_mls_agent'] ),
787 + 'post_status' => (string) ( $mlsimportItemOptionData['mlsimport_item_property_status'] ?? 'publish' ),
788 + 'field_configuration' => is_array( $mlsimportItemOptionData['mlsimport_field_configuration'] ?? null )
789 + ? $mlsimportItemOptionData['mlsimport_field_configuration']
790 + : array(),
791 + 'title_format' => (string) ( $mlsimportItemOptionData['mlsimport_item_title_format'] ?? '' ),
792 + 'config_version' => (string) ( $mlsimportItemOptionData['mlsimport_write_config_version'] ?? '' ),
793 + );
1260 794
1261 - $mlsImportItemStatus = $mlsimportItemOptionData['mlsimport_item_standardstatus'];
1262 - $newAuthor = $mlsimportItemOptionData['mlsimport_item_property_user'];
1263 - $newAgent = $mlsimportItemOptionData['mlsimport_item_agent'];
1264 - $propertyStatus = $mlsimportItemOptionData['mlsimport_item_property_status'];
1265 -
1266 - if (is_array($mlsImportItemStatus)) {
1267 - $mlsImportItemStatus = array_map('mlsimport_normalize_status_enum', $mlsImportItemStatus);
1268 - }
1269 -
1270 - if (!isset($property['ListingKey']) || empty($property['ListingKey'])) {
1271 - $this->writeImportLogs('ERROR: No Listing Key ' . PHP_EOL, $tipImport);
1272 - return;
1273 - }
1274 -
1275 - ob_start();
1276 -
1277 - $ListingKey = $property['ListingKey'];
1278 - $listingPostType = $mlsimport->admin->env_data->get_property_post_type();
1279 -
1280 - // Memory before property ID lookup
1281 - $memBeforeRetrieve = memory_get_usage(true);
1282 -
1283 - $propertyId = intval($this->mlsimportSaasRetrievePropertyById($ListingKey, $listingPostType));
1284 -
1285 - // Memory after property ID lookup
1286 - $memAfterRetrieve = memory_get_usage(true);
1287 -
1288 - $status = isset($property['StandardStatus']) ? mlsimport_normalize_status_enum($property['StandardStatus']) : mlsimport_normalize_status_enum($property['extra_meta']['MlsStatus']);
1289 -
1290 - $this->writeImportLogs('FIxing: on inserting ' .$status.'-->'.json_encode($mlsImportItemStatus). PHP_EOL, $tipImport);
1291 -
1292 - $isInsert = $this->shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport);
1293 -
1294 - $log = $this->mlsimportMemUsage() . '==========' . wp_json_encode($mlsImportItemStatus) . '/' . $newAuthor . '/' . $newAgent . '/' . $propertyStatus . '/ We have property with $ListingKey=' . $ListingKey . ' id=' . $propertyId . ' with status ' . $status . ' is insert? ' . $isInsert . PHP_EOL;
1295 - $this->writeImportLogs($log, $tipImport);
1296 -
1297 - $propertyHistory = [];
1298 - $content = $property['content'] ?? '';
1299 - $submitTitle = $ListingKey;
1300 -
1301 - // Memory before insert/update
1302 - $memBeforeInsert = memory_get_usage(true);
1303 -
1304 - $activityAction = '';
1305 -
1306 - // Incoming MLS modification time (unix). The hourly delta sync uses a
1307 - // rolling 2-hour overlap window, so the SAME listing is returned on
1308 - // several consecutive runs. We compare this against the last value we
1309 - // recorded (mlsimport_synced_mod) to count a listing as "edited" only
1310 - // when it actually changed since our last import — not on every re-touch.
1311 - $incomingMod = isset($property['extra_meta']['ModificationTimestamp'])
1312 - ? strtotime((string) $property['extra_meta']['ModificationTimestamp'])
1313 - : 0;
1314 - if (false === $incomingMod) {
1315 - $incomingMod = 0;
1316 - }
1317 -
1318 - if ($isInsert === 'yes') {
1319 - $post = [
1320 - 'post_title' => $submitTitle,
1321 - 'post_content' => $content,
1322 - 'post_status' => $propertyStatus,
1323 - 'post_type' => $listingPostType,
1324 - 'post_author' => $newAuthor,
1325 - ];
1326 -
1327 - $propertyId = wp_insert_post($post);
1328 -
1329 - if (is_wp_error($propertyId)) {
1330 - $this->writeImportLogs('ERROR: on inserting ' . PHP_EOL, $tipImport);
1331 - } else {
1332 - update_post_meta($propertyId, 'ListingKey', $ListingKey);
1333 - update_post_meta($propertyId, 'MLSimport_item_inserted', $itemIdArray['item_id'],);
1334 - $activityAction = 'added';
1335 - update_post_meta($propertyId, 'mlsimport_synced_mod', $incomingMod);
1336 - $propertyHistory[] = date('F j, Y, g:i a') . ': We Inserted the property with Default title : ' . $submitTitle . ' and received id:' . $propertyId;
1337 - mlsimport_telemetry_bump( 'imported' );
1338 - }
1339 -
1340 - clean_post_cache($propertyId);
1341 -
1342 - } elseif ($propertyId !== 0) {
1343 -
1344 -
1345 - // Memory before checking existing property
1346 - $memBeforeCheck = memory_get_usage(true);
1347 -
1348 - $keep = $this->shouldKeepExistingListing($status, $mlsImportItemStatus);
1349 -
1350 -
1351 - if(!$keep){
1352 - $log = 'Property with ID ' . $propertyId . ' and with name ' . get_the_title($propertyId) . ' has a status of <strong>' . $status . ' </strong> and will be deleted' . PHP_EOL;
1353 -
1354 - // Memory before delete
1355 - $memBeforeDelete = memory_get_usage(true);
1356 -
1357 - $this->deleteProperty($propertyId, $ListingKey);
1358 -
1359 - // Memory after delete
1360 - $memAfterDelete = memory_get_usage(true);
1361 -
1362 - $this->writeImportLogs($log, $tipImport);
1363 - } else {
1364 - // Memory before updating
1365 - $memBeforeUpdate = memory_get_usage(true);
1366 -
1367 - $propertyHistory = $this->updateExistingProperty($propertyId, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, $propertyHistory, $tipImport, $ListingKey);
1368 -
1369 - // Count as "edited" only when the listing actually changed since our
1370 - // last import. A missing incoming timestamp (0) means we can't tell,
1371 - // so fall back to recording the edit.
1372 - $storedMod = (int) get_post_meta($propertyId, 'mlsimport_synced_mod', true);
1373 - if (0 === $incomingMod || $incomingMod > $storedMod) {
1374 - $activityAction = 'edited';
1375 - if ($incomingMod > 0) {
1376 - update_post_meta($propertyId, 'mlsimport_synced_mod', $incomingMod);
1377 - }
1378 - }
1379 -
1380 - // Memory after updating
1381 - $memAfterUpdate = memory_get_usage(true);
1382 - }
1383 - }
1384 -
1385 - // Memory after insert/update
1386 - $memAfterInsert = memory_get_usage(true);
1387 -
1388 - if ($propertyId === 0) {
1389 - $this->writeImportLogs('ERROR property id is 0' . PHP_EOL, $tipImport);
1390 - return;
1391 - }
1392 -
1393 - // Memory before processing details
1394 - $memBeforeDetails = memory_get_usage(true);
1395 -
1396 - $newTitle = $this->processPropertyDetails($property, $propertyId, $tipImport, $propertyHistory, $newAgent, $itemIdArray,$isInsert);
1397 -
1398 - if ( $activityAction !== '' ) {
1399 - mlsimport_record_activity( $activityAction, $propertyId, $ListingKey, isset($itemIdArray['item_id']) ? intval($itemIdArray['item_id']) : 0, $tipImport );
1400 - }
1401 -
1402 - // Memory after processing details
1403 - $memAfterDetails = memory_get_usage(true);
1404 -
1405 - $log = PHP_EOL . 'Ending on Property ' . $propertyId . ', ListingKey: ' . $ListingKey . ' , is insert? ' . $isInsert . ' with new title: ' . $newTitle . ' ' . PHP_EOL;
1406 - $this->writeImportLogs($log, $tipImport);
1407 -
1408 - clean_post_cache($propertyId);
1409 -
1410 - // More aggressive memory cleanup
1411 - // First clear specific large arrays in property data
1412 - if (isset($property['Media']) && is_array($property['Media'])) {
1413 - foreach ($property['Media'] as $key => $media) {
1414 - unset($property['Media'][$key]);
1415 - }
1416 - }
1417 - if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1418 - foreach ($property['extra_meta'] as $key => $value) {
1419 - unset($property['extra_meta'][$key]);
1420 - }
1421 - }
1422 - if (isset($property['meta']) && is_array($property['meta'])) {
1423 - foreach ($property['meta'] as $key => $value) {
1424 - unset($property['meta'][$key]);
1425 - }
1426 - }
1427 - if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1428 - foreach ($property['taxonomies'] as $key => $value) {
1429 - unset($property['taxonomies'][$key]);
1430 - }
1431 - }
1432 -
1433 - // Then unset the main arrays
1434 - unset($property['Media']);
1435 - unset($property['extra_meta']);
1436 - unset($property['meta']);
1437 - unset($property['taxonomies']);
1438 - unset($property);
1439 -
1440 - // Clear any post caches that might have been created
1441 - clean_post_cache($propertyId);
1442 -
1443 - // Clear other variables that hold large data
1444 - unset($log);
1445 - unset($propertyHistory);
1446 - $GLOBALS['wpdb']->queries = array();
1447 -
1448 - // Clear WordPress specific caches
1449 - wp_cache_delete('get_term_meta', 'terms');
1450 - wp_cache_delete('terms', 'terms');
1451 - wp_cache_delete('term_meta', 'terms');
1452 - wp_cache_delete('get_terms', 'terms');
1453 -
1454 - // Clear post related caches
1455 - wp_cache_delete('post_meta_' . $propertyId, 'post_meta');
1456 - wp_cache_delete($propertyId, 'posts');
1457 -
1458 - // Force multiple garbage collection cycles
1459 - gc_collect_cycles();
1460 - gc_collect_cycles();
1461 -
1462 - // Close and discard any output buffer content
1463 - ob_end_clean();
1464 -
1465 - // Try to trigger PHP's internal memory cleanup
1466 - $dummy = str_repeat('x', 1024 * 1024);
1467 - unset($dummy);
1468 -
1469 - // Final memory usage
1470 - $memEnd = memory_get_usage(true);
1471 - $memEndMB = round($memEnd / 1048576, 2);
1472 - $memDiff = round(($memEnd - $memStart) / 1048576, 2);
1473 -
1474 - // If we see a significant memory increase, log a warning
1475 - if ($memDiff > 5) {
1476 - }
1477 -
1478 - // Restore WordPress hooks
1479 - global $wp_filter;
1480 - if (!empty($saved_filters)) {
1481 - foreach ($saved_filters as $hook => $filter) {
1482 - $wp_filter[$hook] = $filter;
1483 - }
1484 - }
795 + return $this->stored_listing_write->write( $property, $settings );
1485 796 }
1486 797
1487 798
1488 799
@@ -1488,60 +799,10 @@
1488 799
1489 800
1490 801
1491 802
1492 - /**
1493 - * Check if the property should be inserted
1494 - *
1495 - * @param int $propertyId The property ID.
1496 - * @param string $status The property status.
1497 - * @param array $mlsImportItemStatus The MLS import item status.
1498 - * @param string $tipImport The import type.
1499 - * @return string 'yes' or 'no' indicating if the property should be inserted.
1500 - */
1501 - private function shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport): string{
1502 - $this->writeImportLogs(
1503 - "Checking: on inserting {$propertyId}={$status} vs " .
1504 - json_encode($mlsImportItemStatus) . " -- {$tipImport}" . PHP_EOL,
1505 - $tipImport
1506 - );
1507 803
1508 -
1509 - if ($propertyId !== 0 || !is_array($mlsImportItemStatus)) {
1510 - return 'no';
1511 -
1512 - }
1513 -
1514 - $activeStatuses = [
1515 - 'active',
1516 - 'active under contract',
1517 - 'active with contract',
1518 - 'activewithcontract',
1519 - 'status',
1520 - 'activeundercontract',
1521 - 'comingsoon',
1522 - 'coming soon',
1523 - 'pending'
1524 - ];
1525 - if(is_array($mlsImportItemStatus)){
1526 - if (!in_array(strtolower($status), $mlsImportItemStatus, true)) {
1527 - return 'no';
1528 - }
1529 804
1530 - if ($tipImport === 'cron' && !in_array($status, $mlsImportItemStatus, true)) {
1531 - return 'no';
1532 - }
1533 -
1534 - }else{
1535 - if(!in_array($status, $activeStatuses, true) ){
1536 - return 'no';
1537 - }
1538 - }
1539 -
1540 - return 'yes';
1541 - }
1542 -
1543 -
1544 805 /**
1545 806 * Check for property status against MLS item delete status to see if we keep or delete the listing.
1546 807 * @param int $property_id
1547 808 * @param string|array $mlsImportItemStatus
@@ -1548,35 +809,26 @@
1548 809 * @return bool True to keep, false to delete
1549 810 */
1550 811 public function check_if_delete_when_status($property_id, $mlsImportItemStatus, $mlsImportItemStatusDelete = null, $mlsImportItemStatusProtect = null) {
1551 812
1552 - // Get post_status based on post type/taxonomy. The status taxonomy is
1553 - // resolved from the user's StandardStatus field mapping (theme default as
1554 - // fallback): a task can map status onto a different taxonomy than the
1555 - // hardcoded default, and reading the wrong one returns an empty status that
1556 - // wrongly deletes live listings during reconciliation.
1557 - $post_status = '';
1558 - $mlsimport_status_tax_map = ( $mlsimport_fields_opt = get_option('mlsimport_admin_fields_select') ) && isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
813 + // Resolve the taxonomy field-map, then read the property's current status term.
814 + $mlsimport_fields_opt = mlsimport_active_field_configuration();
815 + $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
1559 816 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
1560 - if (post_type_exists('estate_property')) {
1561 - $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_status'));
1562 - if (!empty($terms) && is_array($terms)) {
1563 - $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1564 - }
1565 - } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1566 - $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_label'));
1567 - if (!empty($terms) && is_array($terms)) {
1568 - $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1569 - }
1570 - } else {
1571 - $post_status = mlsimport_normalize_status_enum(get_post_meta($property_id, 'inspiry_property_label', true));
1572 - }
817 + $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
1573 818
1574 819 // Protected statuses: keep if property status matches
1575 820 if (!empty($mlsImportItemStatusProtect)) {
821 + // An unreadable status cannot prove the listing is NOT protected — keep and log.
822 + if ('' === $post_status) {
823 + $this->writeImportLogs('Property with id ' . $property_id . ' KEPT: status unreadable, cannot check it against Protected Statuses' . PHP_EOL, 'delete');
824 + return true;
825 + }
826 + // Normalise the protect list to space-free enum keys.
1576 827 $mlsImportItemStatusProtect = is_array($mlsImportItemStatusProtect)
1577 828 ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatusProtect)
1578 829 : array(mlsimport_normalize_status_enum($mlsImportItemStatusProtect));
830 + // Property status is protected → keep it.
1579 831 if (in_array($post_status, $mlsImportItemStatusProtect, true)) {
1580 832 return true;
1581 833 }
1582 834 }
@@ -1587,8 +839,15 @@
1587 839
1588 840
1589 841
1590 842
843 +/**
844 + * Manual-import variant of the keep/delete status check.
845 + *
846 + * @param int $property_id The property post ID.
847 + * @param array|string $mlsImportItemStatus Task's selected statuses.
848 + * @return bool True if the property's status matches the selected set.
849 + */
1591 850 public function check_if_delete_when_status_on_manual_import($property_id, $mlsImportItemStatus) {
1592 851 // Normalize status arrays/strings to a space-free comparison key so
1593 852 // Trestle PrettyEnums labels match the raw enum config values.
1594 853 $mlsImportItemStatus = is_array($mlsImportItemStatus)
@@ -1594,33 +853,21 @@
1594 853 $mlsImportItemStatus = is_array($mlsImportItemStatus)
1595 854 ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatus)
1596 855 : mlsimport_normalize_status_enum($mlsImportItemStatus);
1597 856
1598 - // Get post_status based on post type/taxonomy. The status taxonomy is
1599 - // resolved from the user's StandardStatus field mapping (theme default as
1600 - // fallback): a task can map status onto a different taxonomy than the
1601 - // hardcoded default, and reading the wrong one returns an empty status that
1602 - // wrongly deletes live listings during reconciliation.
1603 - $post_status = '';
1604 - $mlsimport_status_tax_map = ( $mlsimport_fields_opt = get_option('mlsimport_admin_fields_select') ) && isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
857 + // Resolve the taxonomy field-map, then read the property's current status term.
858 + $mlsimport_fields_opt = mlsimport_active_field_configuration();
859 + $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
1605 860 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
1606 - if (post_type_exists('estate_property')) {
1607 - $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_status'));
1608 - if (!empty($terms) && is_array($terms)) {
1609 - $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1610 - }
1611 - } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1612 - $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_label'));
1613 - if (!empty($terms) && is_array($terms)) {
1614 - $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1615 - }
1616 - } else {
1617 - $post_status = mlsimport_normalize_status_enum(get_post_meta($property_id, 'inspiry_property_label', true));
861 + $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
862 +
863 + // An unreadable status is our read failing, not proof the listing should go — keep and log.
864 + if ('' === $post_status) {
865 + $this->writeImportLogs('Property with id ' . $property_id . ' KEPT: status unreadable, deletion requires a readable status' . PHP_EOL, 'delete');
866 + return true;
1618 867 }
1619 868
1620 -
1621 -
1622 - // Keep if status matches "keep" status
869 + // Keep if status matches "keep" status (array membership or scalar equality).
1623 870 if ((is_array($mlsImportItemStatus) && in_array($post_status, $mlsImportItemStatus, true)) ||
1624 871 (!is_array($mlsImportItemStatus) && $post_status === $mlsImportItemStatus)) {
1625 872
1626 873 return true;
@@ -1627,29 +874,13 @@
1627 874 }
1628 875
1629 876
1630 877
1631 - // Default: keep
878 + // Default: status read but doesn't match the task's selection → delete.
1632 879 return false;
1633 880 }
1634 881
1635 882
1636 -/**
1637 - * Decide whether to keep an existing listing the (filtered) feed returned again.
1638 - *
1639 - * Uses the live MLS status — the SAME basis shouldInsertProperty() uses to
1640 - * decide an insert. The old code compared the stored property_status taxonomy
1641 - * term instead, which can be empty, remapped, or theme-labeled; when it did not
1642 - * equal the RESO status, keep said "delete" while insert said "yes", producing
1643 - * an add/delete/add cycle on every sync. See issue #152.
1644 - *
1645 - * @param string $status Live MLS StandardStatus (lowercased).
1646 - * @param array|string $mlsImportItemStatus Task's selected RESO statuses (lowercased).
1647 - * @return bool True to keep/update, false to delete.
1648 - */
1649 -public function shouldKeepExistingListing($status, $mlsImportItemStatus): bool {
1650 - return is_array($mlsImportItemStatus) && in_array($status, $mlsImportItemStatus, true);
1651 -}
1652 883
1653 884
1654 885
1655 886
@@ -1658,38 +889,29 @@
1658 889 * Check if we should keep or delete the listing when still in MLS.
1659 890 * true we keep
1660 891 */
1661 892 public function check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect = null) {
1662 - $post_status = '';
1663 -
1664 - // Resolve the status taxonomy from the user's StandardStatus field
1665 - // mapping (theme default as fallback) — see note in check_if_delete_when_status().
1666 - $mlsimport_status_tax_map = ( $mlsimport_fields_opt = get_option('mlsimport_admin_fields_select') ) && isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
893 + // Resolve the taxonomy field-map, then read the property's current status term.
894 + $mlsimport_fields_opt = mlsimport_active_field_configuration();
895 + $mlsimport_status_tax_map = isset($mlsimport_fields_opt['mls-fields-map-taxonomy'])
1667 896 ? $mlsimport_fields_opt['mls-fields-map-taxonomy'] : array();
897 + $post_status = mlsimport_read_property_status($property_id, $mlsimport_status_tax_map);
1668 898
1669 - // Check for post status based on post type/taxonomy
1670 - if (post_type_exists('estate_property')) {
1671 - // WPResidence
1672 - $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_status'));
1673 - if (!empty($terms) && is_array($terms)) {
1674 - $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1675 - }
1676 - } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1677 - // Houzez
1678 - $terms = get_the_terms($property_id, mlsimport_status_taxonomy($mlsimport_status_tax_map, 'property_label'));
1679 - if (!empty($terms) && is_array($terms)) {
1680 - $post_status = mlsimport_normalize_status_enum($terms[0]->name);
1681 - }
1682 - } else {
1683 - // RealHomes
1684 - $post_status = mlsimport_normalize_status_enum(get_post_meta($property_id, 'inspiry_property_label', true));
899 + // The listing is still in the MLS feed. An unreadable local status is
900 + // never proof it should be deleted (every past mass-deletion incident
901 + // was this read failing) — keep and log.
902 + if ('' === $post_status) {
903 + $this->writeImportLogs('Property with id ' . $property_id . ' KEPT: still in MLS feed but local status unreadable' . PHP_EOL, 'delete');
904 + return true;
1685 905 }
1686 906
1687 907 // Protected statuses: keep if property status matches
1688 908 if (!empty($mlsimport_item_standardstatusprotect)) {
909 + // Normalise the protect list to space-free enum keys.
1689 910 $mlsimport_item_standardstatusprotect = is_array($mlsimport_item_standardstatusprotect)
1690 911 ? array_map('mlsimport_normalize_status_enum', $mlsimport_item_standardstatusprotect)
1691 912 : array(mlsimport_normalize_status_enum($mlsimport_item_standardstatusprotect));
913 + // Protected → keep.
1692 914 if (in_array($post_status, $mlsimport_item_standardstatusprotect, true)) {
1693 915 return true;
1694 916 }
1695 917 }
@@ -1700,11 +922,13 @@
1700 922 }
1701 923
1702 924 // Normalize standard statuses to a space-free key for comparison
1703 925 if (is_array($mlsimport_item_standardstatus)) {
926 + // Array form → keep when the property's status is a member.
1704 927 $mlsimport_item_standardstatus = array_map('mlsimport_normalize_status_enum', $mlsimport_item_standardstatus);
1705 928 return in_array($post_status, $mlsimport_item_standardstatus, true);
1706 929 }
930 + // Scalar form → keep on exact (normalised) match.
1707 931 return $post_status === mlsimport_normalize_status_enum($mlsimport_item_standardstatus);
1708 932 }
1709 933
1710 934
@@ -1712,521 +936,13 @@
1712 936
1713 937
1714 938
1715 939
1716 - /**
1717 - * Update existing property
1718 - *
1719 - * @param int $propertyId The property ID.
1720 - * @param string $content The post content.
1721 - * @param string $listingPostType The listing post type.
1722 - * @param int $newAuthor The new author ID.
1723 - * @param string $status The property status.
1724 - * @param array $mlsImportItemStatus The MLS import item status.
1725 - * @param array $propertyHistory The property history.
1726 - * @param string $tipImport The import type.
1727 - * @param string $ListingKey The listing key.
1728 - * @return array Updated property history.
1729 - */
1730 - private function updateExistingProperty($propertyId, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, &$propertyHistory, $tipImport, $ListingKey) {
1731 -
1732 -
1733 - $post = [
1734 - 'ID' => $propertyId,
1735 - 'post_content' => $content,
1736 - 'post_type' => $listingPostType,
1737 - 'post_author' => $newAuthor,
1738 - ];
1739 940
1740 - $log = 'Property with ID ' . $propertyId . ' and with name ' . get_the_title($propertyId) . ' has a status of <strong>' . $status . '</strong> and will be Edited</br>';
1741 - $this->writeImportLogs($log, $tipImport);
1742 941
1743 - $propertyId = wp_update_post($post);
1744 - if (is_wp_error($propertyId)) {
1745 - $this->writeImportLogs('ERROR: on edit ' . PHP_EOL, $tipImport);
1746 - } else {
1747 - $submitTitle = get_the_title($propertyId);
1748 - $propertyHistory[] = gmdate('F j, Y, g:i a') . ': Property with title: ' . $submitTitle . ', id:' . $propertyId . ', ListingKey:' . $ListingKey . ', Status:' . $status . ' will be edited';
1749 - mlsimport_telemetry_bump( 'updated' );
1750 - }
1751 - clean_post_cache( $propertyId );
1752 -
1753 - return $propertyHistory;
1754 - }
1755 942
1756 -/**
1757 - * Process property details with memory tracking and optimization
1758 - *
1759 - * @param array $property The property data.
1760 - * @param int $propertyId The property ID.
1761 - * @param string $tipImport The import type.
1762 - * @param array $propertyHistory The property history.
1763 - * @param int $newAgent The new agent ID.
1764 - * @param array $itemIdArray The item ID array.
1765 - * @param string $isInsert If is a property insert
1766 - */
1767 -private function processPropertyDetails($property, $propertyId, $tipImport, &$propertyHistory, $newAgent, $itemIdArray, $isInsert) {
1768 - global $mlsimport, $wpdb;
1769 -
1770 943
1771 944
1772 - // Normalize timestamp fields in extra_meta to format like "May 17, 2025 at 06:26am"
1773 - if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1774 - $timestampFields = [
1775 - 'StatusChangeTimestamp',
1776 - 'STELLAR_BOMDate',
1777 - 'PriceChangeTimestamp',
1778 - 'PhotosChangeTimestamp',
1779 - 'BridgeModificationTimestamp',
1780 - 'ModificationTimestamp',
1781 - 'OriginalEntryTimestamp',
1782 - 'MajorChangeTimestamp'
1783 - ];
1784 -
1785 - foreach ($timestampFields as $tsField) {
1786 - if (!empty($property['extra_meta'][$tsField])) {
1787 - $timestamp = strtotime($property['extra_meta'][$tsField]);
1788 - if ($timestamp !== false) {
1789 - $property['extra_meta'][$tsField] = gmdate('F j, Y \a\t h:ia', $timestamp);
1790 - }
1791 - }
1792 - }
1793 - }
1794 -
1795 -
1796 -
1797 - // 1. DISABLE AUTOCOMMIT FOR BATCH PROCESSING
1798 - // This reduces memory by preventing DB auto-commits between operations
1799 - if (method_exists($wpdb, 'query')) {
1800 - $wpdb->query('SET autocommit = 0');
1801 - }
1802 -
1803 - // 2. TEMPORARY DISABLE ACTIONS THAT CONSUME MEMORY
1804 - $suspended_actions = [];
1805 - foreach (['save_post', 'added_post_meta', 'updated_post_meta'] as $action) {
1806 - if (has_action($action)) {
1807 - $suspended_actions[$action] = true;
1808 - remove_all_actions($action);
1809 - }
1810 - }
1811 -
1812 - // Initial memory
1813 - $memStart = memory_get_usage(true);
1814 -
1815 - $log = PHP_EOL . $this->mlsimportMemUsage() . '====before tax======' . PHP_EOL;
1816 - $this->writeImportLogs($log, $tipImport);
1817 -
1818 - // 3. OPTIMIZE TAXONOMY PROCESSING
1819 - if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1820 - $memBeforeTax = memory_get_usage(true);
1821 -
1822 - // Load taxonomy mapping options
1823 - $options = get_option('mlsimport_admin_fields_select');
1824 - $theme_schema = mlsimport_hardocde_theme_schema();
1825 - $taxonomy_overrides = array();
1826 - if (isset($options['mls-fields-map-taxonomy']) && is_array($options['mls-fields-map-taxonomy'])) {
1827 - foreach ($options['mls-fields-map-taxonomy'] as $field_key => $mapped_tax) {
1828 - if ($mapped_tax === '') {
1829 - continue;
1830 - }
1831 - if (isset($theme_schema[$field_key]) && isset($theme_schema[$field_key]['type']) &&
1832 - $theme_schema[$field_key]['type'] === 'taxonomy' && isset($theme_schema[$field_key]['name'])) {
1833 - $default_tax = $theme_schema[$field_key]['name'];
1834 - if ($default_tax !== $mapped_tax) {
1835 - $taxonomy_overrides[$default_tax] = $mapped_tax;
1836 - }
1837 - }
1838 - }
1839 - }
1840 -
1841 - // Theme-agnostic override. The local hardcoded schema above is the
1842 - // WPResidence mapping, so its default-taxonomy slugs do NOT match the
1843 - // taxonomies the server built when a different theme (e.g. Houzez) was
1844 - // used — the slug-based overrides then silently miss. For the core
1845 - // fields that also arrive with a top-level copy, locate the field's
1846 - // value inside the server-built taxonomies and redirect THAT taxonomy to
1847 - // the user's mapped one. Result: "map field -> Category" moves the value
1848 - // out of the server default and into the chosen taxonomy, on any theme.
1849 - $core_field_value_sources = array(
1850 - 'StandardStatus' => 'StandardStatus',
1851 - 'PropertyType' => 'adr_type',
1852 - 'City' => 'adr_city',
1853 - 'CountyOrParish' => 'adr_county',
1854 - );
1855 - if (isset($options['mls-fields-map-taxonomy']) && is_array($options['mls-fields-map-taxonomy'])) {
1856 - foreach ($core_field_value_sources as $reso_field => $top_level_key) {
1857 - $mapped_tax = isset($options['mls-fields-map-taxonomy'][$reso_field]) ? $options['mls-fields-map-taxonomy'][$reso_field] : '';
1858 - if ($mapped_tax === '' || !isset($property[$top_level_key]) || '' === $property[$top_level_key]) {
1859 - continue;
1860 - }
1861 - $field_value = trim((string) $property[$top_level_key]);
1862 - foreach ($property['taxonomies'] as $server_tax => $server_terms) {
1863 - if ($server_tax === $mapped_tax) {
1864 - continue;
1865 - }
1866 - $term_list = is_array($server_terms) ? $server_terms : array($server_terms);
1867 - $term_list = array_map('trim', array_map('strval', $term_list));
1868 - if (in_array($field_value, $term_list, true)) {
1869 - $taxonomy_overrides[$server_tax] = $mapped_tax;
1870 - }
1871 - }
1872 - }
1873 - }
1874 -
1875 - // Disable term counting temporarily (major memory saver)
1876 - wp_defer_term_counting(true);
1877 -
1878 - remove_filter('get_term_metadata', 'lazyload_term_meta', 10);
1879 - wp_cache_delete('get_ancestors', 'taxonomy');
1880 -
1881 - // Clear existing taxonomies
1882 - $this->mlsimportSaasClearPropertyForTaxonomy($propertyId, $property['taxonomies']);
1883 -
1884 - // 4. PROCESS TAXONOMIES IN CHUNKS
1885 - $taxChunks = array_chunk($property['taxonomies'], 5, true);
1886 - foreach ($taxChunks as $taxChunk) {
1887 - foreach ($taxChunk as $taxonomy => $term) {
1888 - if (isset($taxonomy_overrides[$taxonomy])) {
1889 - $taxonomy = $taxonomy_overrides[$taxonomy];
1890 - }
1891 - wp_cache_delete("{$taxonomy}_term_counts", 'counts');
1892 - $this->mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $term);
1893 - $propertyHistory[] = 'Updated Taxonomy ' . $taxonomy . ' with terms ' . wp_json_encode($term);
1894 -
1895 - // Memory cleanup after each taxonomy
1896 - wp_cache_delete('term_meta', 'terms');
1897 - wp_cache_delete($taxonomy, 'terms');
1898 - }
1899 -
1900 - // 5. FORCE GC AFTER EACH CHUNK
1901 - gc_collect_cycles();
1902 - }
1903 -
1904 - // Restore term filter and clean up
1905 - add_filter('get_term_metadata', 'lazyload_term_meta', 10, 2);
1906 - delete_option('category_children');
1907 -
1908 - // Re-enable term counting
1909 - wp_defer_term_counting(false);
1910 -
1911 - $memAfterTax = memory_get_usage(true);
1912 - // " MB, Total Diff: " . round(($memAfterTax - $memBeforeTax) / 1048576, 2) . " MB");
1913 - }
1914 -
1915 - // 6. FLUSH SPECIFIC CACHES INSTEAD OF ALL
1916 - // More targeted than wp_cache_flush()
1917 - wp_cache_delete('terms', 'terms');
1918 - wp_cache_delete('term_meta', 'terms');
1919 - wp_cache_delete("post_meta_{$propertyId}", 'post_meta');
1920 - wp_cache_delete($propertyId, 'posts');
1921 -
1922 - // Prepare meta data
1923 - $property = $this->mlsimportSaasPrepareMetaForProperty($property);
1924 -
1925 - // 7. BATCH META UPDATES
1926 - if (isset($property['meta']) && is_array($property['meta'])) {
1927 - $memBeforeMeta = memory_get_usage(true);
1928 - $metaCount = count($property['meta']);
1929 -
1930 - // Use direct SQL for batch meta updates if many fields
1931 - if ($metaCount > 0 && method_exists($wpdb, 'prepare')) {
1932 - $meta_values = [];
1933 - foreach ($property['meta'] as $metaName => $metaValue) {
1934 - if (is_array($metaValue)) {
1935 - $metaValue = implode(', ', array_map('trim', $metaValue));
1936 - } else {
1937 - $metaValue = preg_replace('/\s*,\s*/', ', ', trim($metaValue));
1938 - }
1939 -
1940 - // Build history separately
1941 - $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1942 -
1943 - // First delete existing
1944 - $wpdb->delete(
1945 - $wpdb->postmeta,
1946 - ['post_id' => $propertyId, 'meta_key' => $metaName],
1947 - ['%d', '%s']
1948 - );
1949 -
1950 - // Collect for batch insert
1951 - $meta_values[] = $wpdb->prepare(
1952 - "(%d, %s, %s)",
1953 - $propertyId,
1954 - $metaName,
1955 - $metaValue
1956 - );
1957 - }
1958 -
1959 - // Batch insert all meta at once
1960 - if (!empty($meta_values)) {
1961 - $wpdb->query("INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES " .
1962 - implode(", ", $meta_values));
1963 - }
1964 - } else {
1965 - // Dead code - left intentianaly
1966 - // Standard approach for fewer meta fields
1967 - foreach ($property['meta'] as $metaName => $metaValue) {
1968 - if (is_array($metaValue)) {
1969 - $metaValue = implode(', ', array_map('trim', $metaValue));
1970 - } else {
1971 - $metaValue = preg_replace('/\s*,\s*/', ', ', trim($metaValue));
1972 - }
1973 - update_post_meta($propertyId, $metaName, $metaValue);
1974 - $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1975 - }
1976 - }
1977 -
1978 - $memAfterMeta = memory_get_usage(true);
1979 - // " MB, Diff: " . round(($memAfterMeta - $memBeforeMeta) / 1048576, 2) . " MB");
1980 - }
1981 -
1982 - // Extra meta processing
1983 - $extraMetaResult = $mlsimport->admin->env_data->mlsimportSaasSetExtraMeta($propertyId, $property);
1984 - if (isset($extraMetaResult['property_history'])) {
1985 - $propertyHistory = array_merge($propertyHistory, (array)$extraMetaResult['property_history']);
1986 - }
1987 -
1988 - // 8. PROCESS MEDIA IN CHUNKS
1989 - $memBeforeMedia = memory_get_usage(true);
1990 -
1991 -
1992 - if (isset($property['Media']) && is_array($property['Media'])) {
1993 - $media_attachments=array();
1994 -
1995 - $mediaCount = count($property['Media']);
1996 -
1997 - // Detect if media has changed for existing properties
1998 - $shouldRefreshMedia = false;
1999 - if ($isInsert === 'no') {
2000 - $shouldRefreshMedia = $this->hasMediaChanged($propertyId, $property['Media']);
2001 - if ($shouldRefreshMedia) {
2002 - $this->writeImportLogs('Media changed for property ' . $propertyId . ', refreshing ' . $mediaCount . ' images', $tipImport);
2003 - $this->deleteExistingMlsAttachments($propertyId);
2004 - } else {
2005 - $this->writeImportLogs('Media unchanged for property ' . $propertyId . ', skipping image refresh', $tipImport);
2006 - }
2007 - }
2008 -
2009 - // Sort media by Order field if it exists
2010 - if (isset($property['Media'][0]['Order'])) {
2011 - $order = array_column($property['Media'], 'Order');
2012 - array_multisort($order, SORT_ASC, $property['Media']);
2013 - }
2014 -
2015 - // Process in chunks of 5
2016 - $mediaChunks = array_chunk($property['Media'], 5,true);
2017 - $mediaHistoryParts = [];
2018 -
2019 - // Clear original array to free memory
2020 - $originalMedia = $property['Media'];
2021 -
2022 - // Find featured image in single loop
2023 - $featuredImageKey = null;
2024 - $orderOneKey = null;
2025 -
2026 - // First priority: Look for PreferredPhotoYN = 1
2027 - foreach ($property['Media'] as $key => $mediaItem) {
2028 - // Priority 1: PreferredPhotoYN = 1 (immediate selection)
2029 - if (isset($mediaItem['PreferredPhotoYN']) && $mediaItem['PreferredPhotoYN'] == 1) {
2030 - $featuredImageKey = $key;
2031 - break;
2032 - }
2033 -
2034 -
2035 - // Priority 2: Store Order = 1 key for potential use
2036 - if ($orderOneKey === null && isset($mediaItem['Order']) && $mediaItem['Order'] == 1) {
2037 - $orderOneKey = $key;
2038 - }
2039 -
2040 - }
2041 -
2042 - if ($featuredImageKey === null && $orderOneKey !== null) {
2043 - $featuredImageKey = $orderOneKey;
2044 - }
2045 -
2046 - // Use Order = 1 image if no preferred image was found
2047 - if ($featuredImageKey === null && $orderOneKey !== null) {
2048 - $featuredImageKey = $orderOneKey;
2049 - }
2050 -
2051 - // Priority 3: Use first image if nothing else found
2052 - if ($featuredImageKey === null && !empty($property['Media'])) {
2053 - $featuredImageKey = 0;
2054 - }
2055 -
2056 -
2057 - unset($property['Media']);
2058 -
2059 - if ($isInsert !== 'no' || $shouldRefreshMedia) {
2060 - delete_post_meta($propertyId, 'fave_property_images');
2061 - delete_post_meta($propertyId, 'REAL_HOMES_property_images');
2062 - delete_post_meta($propertyId, 'wpestate_property_gallery');
2063 - }
2064 -
2065 -
2066 - foreach ($mediaChunks as $index => $mediaChunk) {
2067 - $media_attachments = $this->mlsimportSassAttachMediaToPost($propertyId, $mediaChunk, $isInsert,$media_attachments,$featuredImageKey, $shouldRefreshMedia);
2068 - // $mediaHistoryParts[] = $chunkHistory;
2069 -
2070 - // Free memory
2071 - unset($mediaChunk);
2072 - //unset($chunkHistory);
2073 - gc_collect_cycles();
2074 -
2075 - // Incremental progress report
2076 - }
2077 -
2078 -
2079 - // Only rewrite the gallery when we actually (re)built the attachment list
2080 - // (insert or media refresh). On the unchanged-media path $media_attachments
2081 - // is empty, and overwriting would wipe the existing gallery.
2082 - if ($isInsert !== 'no' || $shouldRefreshMedia) {
2083 - $mlsimport->admin->env_data->enviroment_image_save_gallery($propertyId, $media_attachments);
2084 -
2085 - // Guarantee a featured image. When the feed has no preferred photo
2086 - // (PreferredPhotoYN) and no Order == 1 image — as with AMPRE/PropTx,
2087 - // whose PreferredPhotoYN is empty and Order is not 1-based — fall back
2088 - // to the first attached image so the property always has a thumbnail.
2089 - if ( ! empty( $media_attachments ) && ! has_post_thumbnail( $propertyId ) ) {
2090 - set_post_thumbnail( $propertyId, reset( $media_attachments ) );
2091 - }
2092 - }
2093 -
2094 - // Combine all chunks
2095 - // $mediaHistory = implode('</br>', $mediaHistoryParts);
2096 - // $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
2097 -
2098 - // Clean up
2099 - unset($mediaChunks);
2100 - unset($mediaHistoryParts);
2101 - unset($mediaHistory);
2102 - unset($originalMedia);
2103 - } else {
2104 - $mediaHistory = $this->mlsimportSassAttachMediaToPost($propertyId, $property['Media'] ?? [], $isInsert,$featuredImageKey);
2105 - $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
2106 - }
2107 -
2108 -
2109 - $memAfterMedia = memory_get_usage(true);
2110 - // " MB, Diff: " . round(($memAfterMedia - $memBeforeMedia) / 1048576, 2) . " MB");
2111 -
2112 - // Update title
2113 - $newTitle = $this->mlsimportSaasUpdatePropertyTitle($propertyId, $itemIdArray['item_id'], $property);
2114 - $propertyHistory[] = 'Updated title to ' . $newTitle . '</br>';
2115 -
2116 - // Correlation update
2117 - $mlsimport->admin->env_data->correlationUpdateAfter($isInsert, $propertyId, [], $newAgent);
2118 -
2119 - // 9. COMMIT TRANSACTION
2120 - if (method_exists($wpdb, 'query')) {
2121 - $wpdb->query('COMMIT');
2122 - $wpdb->query('SET autocommit = 1');
2123 - }
2124 -
2125 - // Save property history - using direct SQL if history is large
2126 - if (!empty($propertyHistory)) {
2127 - if (intval(get_option('mlsimport-disable-history', 1)) === 1) {
2128 - $propertyHistory[] = '---------------------------------------------------------------</br>';
2129 - $propertyHistory = implode('</br>', $propertyHistory);
2130 -
2131 - // 10. USE DIRECT SQL FOR LARGE HISTORY
2132 - if (strlen($propertyHistory) > 10000 && method_exists($wpdb, 'update')) {
2133 - $wpdb->update(
2134 - $wpdb->postmeta,
2135 - ['meta_value' => $propertyHistory],
2136 - ['post_id' => $propertyId, 'meta_key' => 'mlsimport_property_history'],
2137 - ['%s'],
2138 - ['%d', '%s']
2139 - );
2140 - } else {
2141 - update_post_meta($propertyId, 'mlsimport_property_history', $propertyHistory);
2142 - }
2143 - }
2144 - }
2145 -
2146 - // 11. RESTORE ACTIONS
2147 - if (!empty($suspended_actions)) {
2148 - foreach ($suspended_actions as $action => $true) {
2149 - add_action($action, '_wp_action_exists_' . $action);
2150 - remove_action($action, '_wp_action_exists_' . $action);
2151 - }
2152 - }
2153 -
2154 - // 12. FINAL CLEANUP
2155 - $property = null;
2156 - $propertyHistory = null;
2157 - wp_cache_flush();
2158 - gc_collect_cycles();
2159 -
2160 - // Final memory stats
2161 - $memEnd = memory_get_usage(true);
2162 -
2163 - return $newTitle;
2164 -}
2165 -
2166 -
2167 -/**
2168 - * Check if incoming MLS media differs from existing MLS-imported attachments.
2169 - *
2170 - * Compares incoming MediaURL values against the GUIDs of existing attachments
2171 - * that have the is_mlsimport meta flag. Uses ID-only queries for memory efficiency.
2172 - *
2173 - * @param int $propertyId The property post ID.
2174 - * @param array $incomingMedia Array of media items, each with a 'MediaURL' key.
2175 - * @return bool True if images need refresh, false if unchanged.
2176 - */
2177 -private function hasMediaChanged($propertyId, $incomingMedia) {
2178 - $existing = get_posts([
2179 - 'post_type' => 'attachment',
2180 - 'post_parent' => $propertyId,
2181 - 'post_status' => 'inherit',
2182 - 'meta_key' => 'is_mlsimport',
2183 - 'meta_value' => 1,
2184 - 'fields' => 'ids',
2185 - 'numberposts' => -1,
2186 - ]);
2187 -
2188 - $existingUrls = array_map(function ($id) {
2189 - return get_post_field('guid', $id);
2190 - }, $existing);
2191 -
2192 - $incomingUrls = array_filter(array_column($incomingMedia, 'MediaURL'));
2193 -
2194 - sort($existingUrls);
2195 - sort($incomingUrls);
2196 -
2197 - return $existingUrls !== $incomingUrls;
2198 -}
2199 -
2200 -
2201 -/**
2202 - * Delete all MLS-imported attachments for a property.
2203 - *
2204 - * Only deletes attachments that have the is_mlsimport post meta set to 1.
2205 - * Manually uploaded attachments are preserved.
2206 - *
2207 - * @param int $propertyId The property post ID.
2208 - */
2209 -private function deleteExistingMlsAttachments($propertyId) {
2210 - $mlsAttachments = get_posts([
2211 - 'post_type' => 'attachment',
2212 - 'post_parent' => $propertyId,
2213 - 'post_status' => 'inherit',
2214 - 'meta_key' => 'is_mlsimport',
2215 - 'meta_value' => 1,
2216 - 'fields' => 'ids',
2217 - 'numberposts' => -1,
2218 - ]);
2219 -
2220 - foreach ($mlsAttachments as $attachId) {
2221 - wp_delete_post($attachId, true);
2222 - }
2223 -
2224 - // The old featured image is one of the attachments we just deleted, so the
2225 - // stale _thumbnail_id now points at nothing. Clear it — otherwise
2226 - // has_post_thumbnail() stays true and the fresh featured image is never set.
2227 - delete_post_thumbnail($propertyId);
2228 -}
2229 945
2230 946
2231 947
2232 948 }