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