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