PluginProbe
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings / 7.2
MLSImport: IDX Plugin & MLS Plugin for Real Estate Listings v7.2
7.2.1 7.2 7.1.2 7.1.1 7.1 7.0.4 7.0.6 7.0.7 6.3.8 6.3.7 6.3.6 6.3.5 6.3.4 6.3.3 6.3.1 trunk 5.7.3 5.7.5 5.8.1 5.8.2 5.8.3 5.8.4 5.8.6 6.0.4 6.0.5 All 36 releases
← All changes | includes/ThemeImport.php +537 -1388 6.0.47.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
@@ -26,12 +60,27 @@
26 60 * @return mixed The API response or error message.
27 61 */
28 62
29 63 public function globalApiRequestCurlSaas($method, $valuesArray, $type = 'GET') {
64 +
65 +
30 66 global $mlsimport;
31 - $url = MLSIMPORT_API_URL . $method;
67 +
68 + // Skip validation for token requests
69 + // (the token call is what mints the credential, so it can't require one).
70 + if ($method !== 'token') {
71 + // Ensure a live JWT before any non-token call; bail out with a message on failure.
72 + if (!self::validateAndRefreshToken()) {
73 + return 'Token validation failed';
74 + }
75 + }
76 +
77 + // Build the full endpoint URL from the SaaS base + method path.
78 + $url = MLSIMPORT_API_URL . $method;
79 + // Default headers for the token request (plain text body).
32 80 $headers = ['Content-Type' => 'text/plain'];
33 81
82 + // For authenticated calls, swap to JSON + Bearer token headers.
34 83 if ($method !== 'token') {
35 84 $token = self::getApiToken();
36 85 $headers = [
37 86 'Content-Type' => 'application/json',
@@ -37,9 +86,10 @@
37 86 'Content-Type' => 'application/json',
38 87 'Authorization' => 'Bearer '.$token,
39 88 ];
40 89 }
41 -
90 +
91 + // Assemble the wp_remote_* argument array (long timeout for large payloads).
42 92 $args = [
43 93 'method' => $type,
44 94 'headers' => $headers,
45 95 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null,
@@ -48,17 +98,28 @@
48 98 'httpversion' => '1.1',
49 99 'blocking' => true,
50 100 'user-agent' => $_SERVER['HTTP_USER_AGENT'],
51 101 ];
52 -
53 102
103 +
104 + // Dispatch as GET or POST depending on $type.
54 105 $response = $type === 'GET' ? wp_remote_get($url, $args) : wp_remote_post($url, $args);
55 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 + }
56 116
57 -
117 + // Transport-level failure: return the WP_Error message string.
58 118 if (is_wp_error($response)) {
59 119 return $response->get_error_message();
60 120 } else {
121 + // Otherwise decode the JSON body and return the array (or a decode-error string).
61 122 $body = wp_remote_retrieve_body($response);
62 123
63 124 $toReturn = json_decode($body, true);
64 125 if (json_last_error() !== JSON_ERROR_NONE) {
@@ -88,12 +149,74 @@
88 149 * @param string $type The request type (default is 'GET').
89 150 * @return array The API response data.
90 151 */
91 152
153 + /**
154 + * Fire-and-forget POST to the SaaS API. Refreshes the JWT token (blocking — a
155 + * required separate request); returns false without sending if the token is
156 + * unavailable. Otherwise issues wp_remote_post() with blocking=false, timeout=0.01
157 + * and returns true. The response is never inspected.
158 + *
159 + * @param string $method The API method/path to call.
160 + * @param array $valuesArray The request body data.
161 + * @return bool True if dispatched, false if token unavailable.
162 + */
163 + public static function globalApiRequestSaasFireAndForget( string $method, array $valuesArray ): bool {
164 + if ( ! self::validateAndRefreshToken() ) {
165 + return false;
166 + }
167 +
168 + $token = self::getApiToken();
169 +
170 + wp_remote_post(
171 + MLSIMPORT_API_URL . $method,
172 + [
173 + 'method' => 'POST',
174 + 'timeout' => 0.01,
175 + 'blocking' => false,
176 + 'headers' => [
177 + 'Authorization' => 'Bearer ' . $token,
178 + 'Content-Type' => 'application/json',
179 + ],
180 + 'body' => wp_json_encode( $valuesArray ),
181 + ]
182 + );
183 +
184 + return true;
185 + }
186 +
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 + */
92 201 public static function globalApiRequestSaas($method, $valuesArray, $type = 'GET') {
93 202 global $mlsimport;
203 + // Skip validation for token and mls requests
204 + if ($method !== 'token' && $method !== 'mls') {
205 + // Guarantee a valid token; otherwise return a failure descriptor.
206 + if (!self::validateAndRefreshToken()) {
207 + return [
208 + 'success' => false,
209 + 'error_message' => 'Token validation failed'
210 + ];
211 + }
212 + }
213 +
214 +
215 + // Full endpoint URL.
94 216 $url = MLSIMPORT_API_URL . $method;
95 217
218 + // Attach Bearer auth headers for authenticated methods only.
96 219 $headers = [];
97 220 if ($method !== 'token' && $method !== 'mls') {
98 221 $token = self::getApiToken();
99 222 $headers = [
@@ -102,8 +225,9 @@
102 225 ];
103 226 }
104 227
105 228
229 + // Request arguments (note: always dispatched via wp_remote_post below).
106 230 $args = [
107 231 'method' => $type,
108 232 'timeout' => 45,
109 233 'redirection' => 5,
@@ -112,13 +236,25 @@
112 236 'headers' => $headers,
113 237 'cookies' => [],
114 238 'body' => !empty($valuesArray) ? wp_json_encode($valuesArray) : null,
115 239 ];
240 + // Always POST (even for logical GETs) — the SaaS expects a JSON body.
116 241 $response = wp_remote_post($url, $args);
117 242
118 -// print_r($response);
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 + }
119 255
120 -
256 + // Transport error → structured failure with WP error code/message.
121 257 if (is_wp_error($response)) {
122 258 return [
123 259 'success' => false,
124 260 'error_code' => $response->get_error_code(),
@@ -125,295 +261,228 @@
125 261 'error_message' => esc_html($response->get_error_message())
126 262 ];
127 263 }
128 264
129 - if (isset($response['response']['code']) && $response['response']['code'] === 200) {
130 - $receivedData = json_decode(wp_remote_retrieve_body($response), true);
131 - return $receivedData;
132 - } else {
133 - return ['success' => false];
134 - }
265 + // Extract HTTP status code and raw body.
266 + $status_code = isset($response['response']['code']) ? intval($response['response']['code']) : 0;
267 + $body = wp_remote_retrieve_body($response);
135 268
136 - exit();
137 - }
269 + // 200 → return the decoded payload untouched.
270 + if (200 === $status_code) {
271 + $receivedData = json_decode($body, true);
272 + return $receivedData;
273 + }
138 274
275 + // Non-200: try to pull a human-readable error out of the JSON body.
276 + $error_message = 'Unknown error';
277 + $error_code = $status_code;
139 278
279 + $decoded_body = json_decode($body, true);
280 + if (json_last_error() === JSON_ERROR_NONE && is_array($decoded_body)) {
281 + // Preferred shape: { error: { message, code } }.
282 + if (isset($decoded_body['error']['message'])) {
283 + $error_message = $decoded_body['error']['message'];
284 + if (isset($decoded_body['error']['code'])) {
285 + $error_code = $decoded_body['error']['code'];
286 + }
287 + // Fallback shape: { message }.
288 + } elseif (isset($decoded_body['message'])) {
289 + $error_message = $decoded_body['message'];
290 + }
291 + }
140 292
293 + // Return the normalised error descriptor (the exit() below is unreachable).
294 + return [
295 + 'success' => false,
296 + 'error_code' => $error_code,
297 + 'error_message' => esc_html($error_message),
298 + ];
141 299
300 + exit();
301 + }
142 302
143 -/**
144 - * Parse Result Array with //error_log memory tracking and enhanced memory management
145 - *
146 - * @param array $readyToParseArray The array ready to be parsed.
147 - * @param array $itemIdArray The item ID array.
148 - * @param string $batchKey The batch key.
149 - * @param array $mlsimportItemOptionData The item option data.
150 - */
151 -public function mlsimportSaasParseSearchArrayPerItem($readyToParseArray, $itemIdArray, $batchKey, $mlsimportItemOptionData) {
152 - // Start with aggressive memory cleanup
153 - $this->cleanUpMemory(true);
154 -
155 - // Log initial memory usage
156 - $initialMemory = memory_get_usage(true);
157 - //error_log("MLS Import - START batch {$batchKey} - Memory: " . round($initialMemory / 1048576, 2) . " MB");
158 -
159 - $counterProp = 0;
160 - $processedData = [];
161 -
162 - if (isset($readyToParseArray['data']) && is_array($readyToParseArray['data'])) {
163 - // Log total items to process
164 - $totalItems = count($readyToParseArray['data']);
165 - //error_log("MLS Import - Processing {$totalItems} properties in batch {$batchKey}");
166 -
303 +
167 304
305 + /**
306 + * Check if token is expired and refresh if needed
307 + * Call this before any external API request
308 + *
309 + * @return bool True if token is valid, false if refresh failed
310 + */
311 + private static function validateAndRefreshToken() {
312 + global $mlsimport;
313 +
314 + // Get stored expiry timestamp
315 + $token_expiry = get_option('mlsimport_token_expiry', 0);
316 + $current_time = time();
317 +
318 + // Check if token is expired (now at/after the stored expiry).
319 + if ($current_time >= $token_expiry) {
320 + // Token expired, refresh it
321 + $refresh_result = self::refreshToken();
168 322
323 + // Propagate refresh failure to the caller.
324 + if (!$refresh_result) {
325 + return false;
326 + }
327 + }
169 328
329 + // Token is present and not past expiry.
330 + return true;
331 + }
170 332
171 - // Only keep essential data in memory, discard the rest
172 - foreach ($readyToParseArray['data'] as $key => $property) {
173 -
174 -
175 -
176 - // Save only what's needed from each property
177 - if (isset($property['ListingKey'])) {
178 - $processedData[$key] = $property;
179 - }
180 - // Remove from original array to free memory
181 - unset($readyToParseArray['data'][$key]);
182 - }
183 -
184 - // Complete unset of the original array
185 - unset($readyToParseArray);
186 - $this->cleanUpMemory();
187 -
188 -
189 - $mlsimportItemId = intval($itemIdArray['item_id']);
190 -
191 - $current_prop_value = (int) get_post_meta( $mlsimportItemId, 'mlsimport_progress_properties', true );
192 - //error_log('for itemID '.$mlsimportItemId.' count1 -> before bathich '.$current_prop_value);
193 -
194 -
195 - // Process each property
196 - foreach ($processedData as $key => $property) {
197 - ++$counterProp;
198 -
199 - // Memory usage before processing property
200 - $memoryBefore = memory_get_usage(true);
201 - $memoryBeforeMB = round($memoryBefore / 1048576, 2);
202 -
203 - $listingKey = isset($property['ListingKey']) ? $property['ListingKey'] : 'unknown';
204 - //error_log("MLS Import - Before property {$counterProp}/{$totalItems} (ListingKey: {$listingKey}) - Memory: {$memoryBeforeMB} MB");
205 -
206 - // Clear out database caches that might be polluted
207 - wp_cache_delete('mlsimport_force_stop_' . $itemIdArray['item_id'], 'options');
208 - $GLOBALS['wpdb']->queries = array();
209 -
210 - $status = get_option('mlsimport_force_stop_' . $itemIdArray['item_id']);
211 -
212 - if ($status === 'no') {
213 -
214 -
215 - $current_prop_value = $current_prop_value + 1;
216 - update_post_meta( $mlsimportItemId, 'mlsimport_progress_properties', $current_prop_value );
217 - //error_log(' count2 -> after updateing '.$current_prop_value);
218 -
219 -
220 -
221 - // Process property and track memory
222 - $this->mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, 'normal', $mlsimportItemOptionData);
223 -
224 - // Memory after processing property
225 - $memoryAfter = memory_get_usage(true);
226 - $memoryAfterMB = round($memoryAfter / 1048576, 2);
227 - $memoryDiff = round(($memoryAfter - $memoryBefore) / 1048576, 2);
228 -
229 - //error_log("MLS Import - After property {$counterProp}/{$totalItems} - Memory: {$memoryAfterMB} MB, Diff: {$memoryDiff} MB");
230 -
231 - // Check for memory leak pattern
232 - if ($memoryDiff > 10) {
233 - //error_log("MLS Import - WARNING: Significant memory increase of {$memoryDiff} MB after property {$counterProp}");
234 - // Force cleanup on large increases
235 - $this->cleanUpMemory(true);
236 - }
237 -
238 - // Aggressively clean after each property
239 - unset($property);
240 -
241 - // Periodic more intensive cleanup
242 - if ($counterProp % 3 == 0) {
243 - $this->cleanUpMemory(true);
244 -
245 - // Free database query cache
246 - $GLOBALS['wpdb']->flush();
247 -
248 - // Clear autoloaded options cache, which can grow large
249 - wp_cache_delete('alloptions', 'options');
250 -
251 - // Log memory after cleanup
252 - $memoryAfterCleanup = memory_get_usage(true);
253 - $freedMemory = round(($memoryAfter - $memoryAfterCleanup) / 1048576, 2);
254 - //error_log("MLS Import - After cleanup: {$freedMemory} MB freed, Current: " . round($memoryAfterCleanup / 1048576, 2) . " MB");
255 - }
256 - } else {
257 - //error_log("MLS Import - Import stopped by user command at property {$counterProp}");
258 - update_post_meta($itemIdArray['item_id'], 'mlsimport_spawn_status', 'completed');
259 - break;
260 - }
261 -
262 - // Clear property from processed data to free memory
263 - unset($processedData[$key]);
264 - }
265 - } else {
266 - //error_log("MLS Import - No valid data in batch {$batchKey}");
267 - }
268 -
269 - // Final cleanup
270 - unset($processedData);
271 - $this->cleanUpMemory(true);
272 -
273 - // Log final memory stats
274 - $finalMemory = memory_get_usage(true);
275 - $finalMemoryMB = round($finalMemory / 1048576, 2);
276 - $totalMemoryDiff = round(($finalMemory - $initialMemory) / 1048576, 2);
277 - $peakMemory = round(memory_get_peak_usage(true) / 1048576, 2);
278 -
279 - //error_log("MLS Import - END batch {$batchKey} - Processed {$counterProp} properties");
280 - //error_log("MLS Import - Final Memory: {$finalMemoryMB} MB, Diff: {$totalMemoryDiff} MB, Peak: {$peakMemory} MB");
281 -}
282 -
283 -
284 -/**
285 - * Comprehensive memory cleanup function
286 - *
287 - * @param bool $intensive Whether to perform intensive cleanup
288 - */
289 -private function cleanUpMemory($intensive = false) {
290 - // Basic cleanup
291 - wp_cache_flush();
292 - gc_collect_cycles();
293 -
294 - if ($intensive) {
295 - // Clear WordPress object cache
296 - global $wp_object_cache;
297 - if (is_object($wp_object_cache) && method_exists($wp_object_cache, 'flush')) {
298 - $wp_object_cache->flush();
299 - }
300 -
301 - // Clear WordPress post caches
302 - clean_post_cache(0);
303 -
304 - // Safe term cache clearing - avoid SQL errors
305 - wp_cache_delete('get_terms', 'terms');
306 - wp_cache_delete('term_meta', 'terms');
307 - delete_option('category_children');
308 -
309 - // Clear taxonomy-specific caches for common taxonomies
310 - $taxonomies = array('category', 'post_tag', 'property_status', 'property_type', 'property_feature', 'property_label', 'property_area', 'property_city', 'property_state', 'property_neighborhood');
311 - foreach ($taxonomies as $taxonomy) {
312 - wp_cache_delete($taxonomy . '_relationships', 'terms');
313 - }
314 -
315 - // Clear WordPress database cache
316 - global $wpdb;
317 - if (is_object($wpdb)) {
318 - $wpdb->queries = array();
319 - if (method_exists($wpdb, 'flush')) {
320 - $wpdb->flush();
321 - }
322 - }
323 -
324 - // Multiple garbage collection passes can sometimes help
325 - gc_collect_cycles();
326 - gc_collect_cycles();
327 - }
328 -}
329 -
330 -
331 -
332 -
333 -
334 -
335 -
336 333 /**
337 - * Write logs for import process
334 + * Record the SaaS connection-health state (#208).
338 335 *
339 - * @param string $logs The log message to write.
340 - * @param string $type The type of log.
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
341 346 */
342 - private function writeImportLogs($logs, $type) {
343 - mlsimport_saas_single_write_import_custom_logs($logs, $type);
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 + }
344 370 }
345 371
346 372 /**
347 - * Get memory usage
373 + * Request a fresh JWT from the SaaS 'token' endpoint and cache it.
348 374 *
349 - * @return string The memory usage in MB.
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.
350 383 */
351 - public function mlsimportMemUsage() {
352 - $memUsage = memory_get_usage(true);
353 - $memUsageShow = round($memUsage / 1048576, 2);
354 - return $memUsageShow . 'mb ';
355 - }
384 + private static function refreshToken() {
385 + global $mlsimport;
386 +
387 + // Get credentials for token request
388 + $options = get_option('mlsimport_admin_options');
389 + // Pull the SaaS account credentials out of the plugin options.
390 + $username = isset($options['mlsimport_username']) ? $options['mlsimport_username'] : '';
391 + $password = isset($options['mlsimport_password']) ? $options['mlsimport_password'] : '';
356 392
393 + // No credentials configured → cannot refresh.
394 + if (empty($username) || empty($password)) {
395 + mlsimport_telemetry_bump( 'token_failures' );
396 + self::setConnectionHealth( 'credentials_missing' );
397 + return false;
398 + }
357 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 + }
358 412
359 -
413 + // Prepare token request
414 + $url = MLSIMPORT_API_URL . 'token';
415 + $body = wp_json_encode(array(
416 + 'username' => $username,
417 + 'password' => $password
418 + ));
419 +
420 + $args = array(
421 + 'method' => 'POST',
422 + 'headers' => array(
423 + 'Content-Type' => 'application/json'
424 + ),
425 + 'body' => $body,
426 + 'timeout' => 45
427 + );
428 +
429 + // Make token request
430 + $response = wp_remote_post($url, $args);
360 431
432 + // Transport failure → count and abort (lock released for the next try).
433 + if (is_wp_error($response)) {
434 + mlsimport_telemetry_bump( 'token_failures' );
435 + delete_option( 'mlsimport_token_refresh_lock' );
436 + return false;
437 + }
361 438
362 - /**
363 - * Parse and import property data for a single MLSimport item in CRON.
364 - * Logs memory usage for each significant operation.
365 - *
366 - * @param array $readyToParseArray The array with listing data (from API).
367 - * @param array $itemIdArray The array with current MLSimport item info.
368 - * @param string $batchKey The batch identifier for logging.
369 - */
370 - public function mlsimportSaasCronParseSearchArrayPerItem($readyToParseArray, $itemIdArray, $batchKey) {
371 - // Gather relevant meta for this MLSimport item
372 - $mlsimportItemOptionData = [
373 - 'mlsimport_item_standardstatus' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_standardstatus', true),
374 - 'mlsimport_item_standardstatusdelete' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_standardstatusdelete', true),
375 - 'mlsimport_item_property_user' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_property_user', true),
376 - 'mlsimport_item_agent' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_agent', true),
377 - 'mlsimport_item_property_status' => get_post_meta($itemIdArray['item_id'], 'mlsimport_item_property_status', true),
378 - ];
439 + // Decode the JSON token response.
440 + $body = wp_remote_retrieve_body($response);
441 + $data = json_decode($body, true);
442 + $code = intval( $response['response']['code'] ?? 0 );
379 443
380 - $count = isset($readyToParseArray['data']) && is_array($readyToParseArray['data']) ? count($readyToParseArray['data']) : 0;
381 - $log = '[Memory] Start batch ' . $batchKey . ' with ' . $count . ' listings: ' . (memory_get_usage(true) / 1024 / 1024) . ' MB';
382 - $this->writeImportLogs($log, 'cron');
444 + // Reject any response missing success/token/expires.
445 + if (!isset($data['success']) || !$data['success'] || !isset($data['token']) || !isset($data['expires'])) {
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 + }
458 + return false;
459 + }
383 460
384 - if ($count === 0) {
385 - $this->writeImportLogs('[Memory] No data to parse in batch ' . $batchKey, 'cron');
386 - return;
387 - }
461 + // A working login wipes any remembered failure reason (#322).
462 + mlsimport_account_status_record( $data );
463 +
464 + // Store new token and expiry
465 + //$mlsimport->admin->mlsimport_saas_store_mls_api_token_transient($data['token']);
388 466
389 - foreach ($readyToParseArray['data'] as $key => $property) {
390 - // Log at the start of each property (optional, comment out if too verbose)
391 - //$log = '[Memory] Before import property #' . $key . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB';
392 - //$this->writeImportLogs($log, 'cron');
467 + // Cache the token in a transient sized to its remaining lifetime.
468 + $expires_in = $data['expires'] - time();
469 + set_transient('mlsimport_saas_token', $data['token'], $expires_in);
393 470
394 - $logs = 'In CRON parse search array, listing no ' . $key . ' from batch ' . $batchKey . ' with ListingKey: ' . $property['ListingKey'] . PHP_EOL;
395 - $this->writeImportLogs($logs, 'cron');
471 + // Persist the absolute expiry so validateAndRefreshToken() can compare against it.
472 + update_option('mlsimport_token_expiry', intval($data['expires']));
396 473
397 - // Main per-property import function (handles mapping/import/update)
398 - $this->mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, 'cron', $mlsimportItemOptionData);
474 + // First successful SaaS account connection (lifecycle telemetry).
475 + mlsimport_telemetry_set_once( 'account_connected_at', time() );
399 476
400 - // Clean up per-iteration memory
401 - unset($property);
402 - if (($key + 1) % 20 === 0) {
403 - gc_collect_cycles();
404 - $log = '[Memory] After importing ' . ($key + 1) . ' listings in batch ' . $batchKey . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB';
405 - $this->writeImportLogs($log, 'cron');
406 - }
407 - }
408 - // Final memory log for this batch
409 - $this->writeImportLogs('[Memory] End batch ' . $batchKey . ': ' . (memory_get_usage(true) / 1024 / 1024) . ' MB', 'cron');
477 + // Refresh finished — release the single-flight lock.
478 + delete_option( 'mlsimport_token_refresh_lock' );
410 479
411 - // Housekeeping
412 - unset($readyToParseArray, $mlsimportItemOptionData);
413 - gc_collect_cycles();
414 - }
480 + // A minted token proves the account works → back to healthy.
481 + self::setConnectionHealth( 'healthy' );
415 482
483 + return true;
484 + }
416 485
417 486
418 487
419 488
@@ -420,395 +489,72 @@
420 489
421 490
422 491
423 492
493 +
424 494
425 495
426 496 /**
427 - * Check if property already imported
497 + * Write logs for import process
428 498 *
429 - * @param string $key The key to search for.
430 - * @param string $postType The post type to search within (default is 'estate_property').
431 - * @return int The post ID if found, or 0 if not found.
499 + * @param string $logs The log message to write.
500 + * @param string $type The type of log.
432 501 */
433 - public function mlsimportSaasRetrievePropertyById($key, $postType = 'estate_property') {
434 - $args = [
435 - 'post_type' => $postType,
436 - 'post_status' => 'any',
437 - 'meta_query' => [
438 - [
439 - 'key' => 'ListingKey',
440 - 'value' => $key,
441 - 'compare' => '=',
442 - ],
443 - ],
444 - 'fields' => 'ids',
445 - ];
446 -
447 - $query = new WP_Query($args);
448 - if ($query->have_posts()) {
449 - $query->the_post();
450 - $propertyId = get_the_ID();
451 - wp_reset_postdata();
452 - return $propertyId;
453 - } else {
454 - wp_reset_postdata();
455 - return 0;
456 - }
502 + private function writeImportLogs($logs, $type) {
503 + mlsimport_saas_single_write_import_custom_logs($logs, $type);
457 504 }
458 505
459 506
460 507
461 508
462 - /**
463 - * Clear taxonomy
464 - *
465 - * @param int $propertyId The property ID.
466 - * @param array $taxonomies The taxonomies to clear.
467 - */
468 - public function mlsimportSaasClearPropertyForTaxonomy($propertyId, $taxonomies) {
469 - if (is_array($taxonomies)) {
470 - foreach ($taxonomies as $taxonomy => $term) {
471 - if (is_wp_error($taxonomy)) {
472 -
473 - continue; // Skip this iteration
474 - }
475 -
476 - if (taxonomy_exists($taxonomy)) {
477 - wp_delete_object_term_relationships($propertyId, $taxonomy);
478 - } else {
479 - // //error_log("Taxonomy does not exist: {$taxonomy}");
480 - }
481 - }
482 - }
483 - }
509 +
484 510
485 511
486 512
487 513
488 514
489 - /**
490 - * Set taxonomy for property
491 - *
492 - * @param string $taxonomy The taxonomy to set.
493 - * @param int $propertyId The property ID.
494 - * @param mixed $fieldValues The values to set.
495 - */
496 - public function mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $fieldValues) {
497 - global $wpdb;
498 515
499 - // Convert comma-separated values to array if necessary
500 - if (!is_array($fieldValues)) {
501 - $fieldValues = strpos($fieldValues, ',') !== false ? explode(',', $fieldValues) : [$fieldValues];
502 - }
503 516
504 - // Trim values and remove empty ones
505 - $fieldValues = array_filter(array_map('trim', $fieldValues));
506 517
507 - // Start a database transaction
508 - $wpdb->query('START TRANSACTION');
509 - $taxLog = [];
510 518
511 - foreach (array_chunk($fieldValues, 5) as $chunk) {
512 - foreach ($chunk as $value) {
513 - if (!empty($value)) {
514 - // Check if the term already exists
515 - $term = $wpdb->get_row($wpdb->prepare(
516 - "SELECT t.*, tt.* FROM $wpdb->terms t
517 - INNER JOIN $wpdb->term_taxonomy tt ON t.term_id = tt.term_id
518 - WHERE t.name = %s AND tt.taxonomy = %s",
519 - $value, $taxonomy
520 - ));
521 519
522 - $taxLog[] = json_encode($term);
523 - if (is_null($term)) {
524 - // Insert the term if it doesn't exist
525 - $wpdb->insert($wpdb->terms, [
526 - 'name' => $value,
527 - 'slug' => sanitize_title($value),
528 - 'term_group' => 0
529 - ]);
530 520
531 - $termId = $wpdb->insert_id;
532 521
533 - if ($termId) {
534 - // Insert term taxonomy
535 - $wpdb->insert($wpdb->term_taxonomy, [
536 - 'term_id' => $termId,
537 - 'taxonomy' => $taxonomy,
538 - 'description' => '',
539 - 'parent' => 0,
540 - 'count' => 0
541 - ]);
542 522
543 - $termTaxonomyId = $wpdb->insert_id;
544 - } else {
545 - $taxLog[] = 'Error inserting term';
546 - continue;
547 - }
548 - } else {
549 - // Term exists, get term_id and term_taxonomy_id
550 - $termId = $term->term_id;
551 - $termTaxonomyId = $wpdb->get_var($wpdb->prepare(
552 - "SELECT term_taxonomy_id FROM $wpdb->term_taxonomy WHERE term_id = %d AND taxonomy = %s",
553 - $termId, $taxonomy
554 - ));
555 - }
556 523
557 - if (!empty($termTaxonomyId)) {
558 - // Insert term relationship
559 - $wpdb->replace($wpdb->term_relationships, [
560 - 'object_id' => $propertyId,
561 - 'term_taxonomy_id' => $termTaxonomyId
562 - ]);
563 - // Increment the term count
564 - $wpdb->query($wpdb->prepare(
565 - "UPDATE $wpdb->term_taxonomy SET count = count + 1 WHERE term_taxonomy_id = %d",
566 - $termTaxonomyId
567 - ));
568 - } else {
569 - $taxLog[] = 'Error: term_taxonomy_id is null';
570 - }
571 - }
572 - }
573 - // Flush the cache to free up memory
574 - wp_cache_flush();
575 - // Run garbage collection
576 - gc_collect_cycles();
577 - }
578 - // Commit the transaction
579 - $wpdb->query('COMMIT');
580 524
581 - // Clear term cache selectively
582 - wp_cache_delete("{$taxonomy}_terms", 'terms');
583 - wp_cache_delete("{$taxonomy}_children", 'terms');
584 -
585 - // Restore the term metadata filter
586 - add_filter('get_term_metadata', [$wpdb->terms, 'cache_term_counts'], 10, 2);
587 525
588 - // Log memory usage
589 - // if (!empty($taxLog)) {
590 - // $taxLogStr = implode(PHP_EOL, $taxLog);
591 - // mlsimport_saas_single_write_import_custom_logs($taxLogStr, 'normal');
592 - // unset($taxLogStr);
593 - // }
594 - }
595 526
596 527
597 528
598 529
599 - /**
600 - * Set Property Title
601 - *
602 - * @param int $propertyId The property ID.
603 - * @param int $mlsImportPostId The MLS import post ID.
604 - * @param array $property The property data.
605 - * @return string The updated title format.
606 - */
607 - public function mlsimportSaasUpdatePropertyTitle($propertyId, $mlsImportPostId, $property) {
608 - global $mlsimport;
609 530
610 - $titleFormat = esc_html(get_post_meta($mlsImportPostId, 'mlsimport_item_title_format', true));
611 531
612 - if ('' === $titleFormat) {
613 - $options = get_option('mlsimport_admin_mls_sync');
614 - $titleFormat = $options['title_format'];
615 - }
616 532
617 - $titleArray = $this->strBetweenAll($titleFormat, '{', '}');
618 533
619 - $propertyExtraMetaArrayLowerCase = array_change_key_case($property['extra_meta'], CASE_LOWER);
620 534
621 - foreach ($titleArray as $key => $value) {
622 - $replace = '';
623 - switch ($value) {
624 - case 'Address':
625 - $replace = $property['adr_title'] ?? '';
626 - break;
627 - case 'City':
628 - $replace = $property['adr_city'] ?? '';
629 - break;
630 - case 'CountyOrParish':
631 - $replace = $property['adr_county'] ?? '';
632 - break;
633 - case 'PropertyType':
634 - $replace = $property['adr_type'] ?? '';
635 - break;
636 - case 'Bedrooms':
637 - $replace = $property['adr_bedrooms'] ?? '';
638 - break;
639 - case 'Bathrooms':
640 - $replace = $property['adr_bathrooms'] ?? '';
641 - break;
642 - case 'ListingKey':
643 - $replace = $property['ListingKey'];
644 - break;
645 - case 'ListingId':
646 - $replace = $property['adr_listingid'] ?? '';
647 - break;
648 - case 'StateOrProvince':
649 - $replace = $property['extra_meta']['StateOrProvince'] ?? '';
650 - break;
651 - case 'PostalCode':
652 - $replace = $property['meta']['property_zip'] ?? $property['meta']['fave_property_zip'] ?? '';
653 - $replace = is_array($replace) ? strval($replace[0]) : strval($replace);
654 - break;
655 - case 'StreetNumberNumeric':
656 - $replace = $propertyExtraMetaArrayLowerCase['streetnumbernumeric'] ?? '';
657 - break;
658 - case 'StreetName':
659 - $replace = $propertyExtraMetaArrayLowerCase['streetname'] ?? '';
660 - break;
661 - }
662 - $titleFormat = str_replace('{' . $value . '}', $replace, $titleFormat);
663 - }
664 535
665 - $post = [
666 - 'ID' => $propertyId,
667 - 'post_title' => $titleFormat,
668 - 'post_name' => $titleFormat,
669 - ];
670 536
671 - wp_update_post($post);
672 -
673 - return $titleFormat;
674 - }
675 -
676 537
677 538
678 539
679 540
680 541
681 - /**
682 - * Prepare meta data for property
683 - *
684 - * @param array $property The property data.
685 - * @return array The property data with prepared meta.
686 - */
687 - public function mlsimportSaasPrepareMetaForProperty($property) {
688 - if (isset($property['extra_meta']['BathroomsTotalDecimal']) && floatval($property['extra_meta']['BathroomsTotalDecimal']) > 0) {
689 - $bathrooms = floatval($property['extra_meta']['BathroomsTotalDecimal']);
690 - $property['meta']['property_bathrooms'] = $bathrooms;
691 - $property['meta']['fave_property_bathrooms'] = $bathrooms;
692 - $property['meta']['REAL_HOMES_property_bathrooms'] = $bathrooms;
693 - }
694 - return $property;
695 - }
696 542
697 543
698 544
699 545
700 546
701 - /**
702 - * Attach media to post
703 - *
704 - * @param int $propertyId The property ID.
705 - * @param array $media The media data.
706 - * @param string $isInsert Whether the property is being inserted.
707 - * @return string The media history log.
708 - */
709 - public function mlsimportSassAttachMediaToPost($propertyId, $media, $isInsert,$media_attachments) {
710 547
711 - $mediaHistory = [];
712 - //error_log("MLSImport: Starting image processing for property ID: $propertyId");
713 548
714 - if ($isInsert === 'no') {
715 - $mediaHistory[] = 'Media - We have edit - images are not replaced';
716 - //error_log("MLSImport: Edit mode detected, images not replaced.");
717 - return $media_attachments;
718 - //return implode('</br>', $mediaHistory);
719 - }
720 549
721 - global $mlsimport;
722 - include_once ABSPATH . 'wp-admin/includes/image.php';
723 - $hasFeatured = false;
724 550
725 551
726 552
727 - add_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']);
728 553
729 - // Sorting media
730 - if (isset($media[0]['Order'])) {
731 - $order = array_column($media, 'Order');
732 - array_multisort($order, SORT_ASC, $media);
733 - //error_log("MLSImport: Media sorted by 'Order'");
734 - }
735 554
736 - if (is_array($media)) {
737 - foreach ($media as $image) {
738 - if (isset($image['MediaCategory']) && $image['MediaCategory'] !== 'Property Photo' && $image['MediaCategory'] !== 'Photo') {
739 - //error_log("MLSImport: Skipping non-photo media category: " . $image['MediaCategory']);
740 - continue;
741 - }
742 555
743 - if ( empty( $image['MediaURL'] ) ) {
744 - //error_log('empty mediaURL');
745 - continue;
746 - }
747 -
748 -
749 -
750 - if (isset($image['MediaURL'])) {
751 - $file = $image['MediaURL'];
752 - $attachment = [
753 - 'guid' => $file,
754 - 'post_status' => 'inherit',
755 - 'post_content' => '',
756 - 'post_parent' => $propertyId,
757 - 'post_mime_type' => $image['MimeType'] ?? 'image/jpeg',
758 - 'post_title' => $image['MediaKey'] ?? '',
759 - ];
760 -
761 - $attachId = wp_insert_attachment($attachment, $file);
762 - if (is_wp_error($attachId)) {
763 - //error_log("MLSImport: Failed to insert attachment for $file. Error: " . $attachId->get_error_message());
764 - } else {
765 - //error_log("MLSImport: Inserted attachment ID $attachId for file $file");
766 - $mediaHistory[] = 'Media - Added ' . $file . ' as attachment ' . $attachId;
767 - $media_attachments[]=$attachId;
768 -
769 -
770 - $mlsimport->admin->env_data->enviroment_image_save($propertyId, $attachId);
771 - update_post_meta($attachId, 'is_mlsimport', 1);
772 - if (!$hasFeatured) {
773 - set_post_thumbnail($propertyId, $attachId);
774 - //error_log("MLSImport: Set attachment ID $attachId as featured image");
775 - $hasFeatured = true;
776 - }
777 - }
778 - } else {
779 - //error_log("MLSImport: Media item missing 'MediaURL', skipping.");
780 - }
781 - }
782 - } else {
783 - $mediaHistory[] = 'Media data is blank - there are no images';
784 - //error_log("MLSImport: Media array is not valid or empty.");
785 - }
786 -
787 - remove_filter('intermediate_image_sizes_advanced', [$this, 'wpcUnsetImageSizes']);
788 -
789 - return $media_attachments;
790 - //return implode('</br>', $mediaHistory);
791 - }
792 -
793 -
794 556 /**
795 - * Unset image sizes
796 - *
797 - * @param array $sizes The sizes to unset.
798 - * @return array The modified sizes array.
799 - */
800 - public function wpcUnsetImageSizes($sizes) {
801 - return [];
802 - }
803 -
804 -
805 -
806 -
807 -
808 -
809 -
810 - /**
811 557 * Return user option
812 558 *
813 559 * @param int $selected The selected user ID.
814 560 * @return string The HTML option elements for users.
@@ -814,11 +560,13 @@
814 560 * @return string The HTML option elements for users.
815 561 */
816 562 public function mlsimportSaasThemeImportSelectUser($selected) {
817 563 $userOptions = '';
564 + // Fetch all users to build a <select> of possible property authors.
818 565 $blogusers = get_users(['blog_id' => 1, 'orderby' => 'nicename']);
819 566 foreach ($blogusers as $user) {
820 567 $userOptions .= '<option value="' . esc_attr($user->ID) . '"';
568 + // Pre-select the currently chosen user.
821 569 if ($user->ID == $selected) {
822 570 $userOptions .= ' selected="selected"';
823 571 }
824 572 $userOptions .= '>' . esc_html($user->user_login) . '</option>';
@@ -839,8 +587,9 @@
839 587 * @return string The HTML option elements for agents.
840 588 */
841 589 public function mlsimportSaasThemeImportSelectAgent($selected) {
842 590 global $mlsimport;
591 + // Query up to 150 published agents of the theme's agent post type.
843 592 $args = [
844 593 'post_type' => $mlsimport->admin->env_data->get_agent_post_type(),
845 594 'post_status' => 'publish',
846 595 'posts_per_page' => 150,
@@ -846,15 +595,18 @@
846 595 'posts_per_page' => 150,
847 596 ];
848 597
849 598 $agentSelection = new WP_Query($args);
599 + // Start with a blank option (no agent).
850 600 $agentOptions = '<option value=""></option>';
851 601
602 + // Build one <option> per agent post.
852 603 while ($agentSelection->have_posts()) {
853 604 $agentSelection->the_post();
854 605 $agentId = get_the_ID();
855 606
856 607 $agentOptions .= '<option value="' . esc_attr($agentId) . '"';
608 + // Pre-select the currently chosen agent.
857 609 if ($agentId == $selected) {
858 610 $agentOptions .= ' selected="selected"';
859 611 }
860 612 $agentOptions .= '>' . esc_html(get_the_title()) . '</option>';
@@ -870,109 +622,19 @@
870 622
871 623
872 624
873 625
874 - /**
875 - * Delete property
876 - *
877 - * @param int $deleteId The ID of the property to delete.
878 - * @param string $ListingKey The listing key of the property.
879 - */
880 - public function deleteProperty($deleteId, $ListingKey) {
881 - if ($deleteId > 0) {
882 - $args = [
883 - 'numberposts' => -1,
884 - 'post_type' => 'attachment',
885 - 'post_parent' => $deleteId,
886 - 'post_status' => null,
887 - 'orderby' => 'menu_order',
888 - 'order' => 'ASC',
889 - ];
890 - $postAttachments = get_posts($args);
891 626
892 - foreach ($postAttachments as $attachment) {
893 - wp_delete_post($attachment->ID);
894 - }
895 627
896 - wp_delete_post($deleteId);
897 - $logEntry = 'Property with id ' . $deleteId . ' and ' . $ListingKey . ' was deleted on ' . current_time('Y-m-d\TH:i') . PHP_EOL;
898 - $this->writeImportLogs($logEntry, 'delete');
899 - }
900 - }
901 628
902 629
903 630
904 631
905 - /**
906 - * Return array with title items
907 - *
908 - * @param string $string The input string.
909 - * @param string $start The start delimiter.
910 - * @param string $end The end delimiter.
911 - * @param bool $includeDelimiters Whether to include the delimiters in the result.
912 - * @param int $offset The offset to start searching from.
913 - * @return array The array of strings found between the delimiters.
914 - */
915 - public function strBetweenAll(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): array {
916 - $strings = [];
917 - $length = strlen($string);
918 632
919 - while ($offset < $length) {
920 - $found = $this->strBetween($string, $start, $end, $includeDelimiters, $offset);
921 - if ($found === null) {
922 - break;
923 - }
924 633
925 - $strings[] = $found;
926 - $offset += strlen($includeDelimiters ? $found : $start . $found . $end); // move offset to the end of the newfound string
927 - }
928 634
929 - return $strings;
930 - }
931 635
932 636 /**
933 - * Find string between delimiters
934 - *
935 - * @param string $string The input string.
936 - * @param string $start The start delimiter.
937 - * @param string $end The end delimiter.
938 - * @param bool $includeDelimiters Whether to include the delimiters in the result.
939 - * @param int $offset The offset to start searching from.
940 - * @return string|null The string found between the delimiters, or null if not found.
941 - */
942 - public function strBetween(string $string, string $start, string $end, bool $includeDelimiters = false, int &$offset = 0): ?string {
943 - if ($string === '' || $start === '' || $end === '') {
944 - return null;
945 - }
946 -
947 - $startLength = strlen($start);
948 - $endLength = strlen($end);
949 -
950 - $startPos = strpos($string, $start, $offset);
951 - if ($startPos === false) {
952 - return null;
953 - }
954 -
955 - $endPos = strpos($string, $end, $startPos + $startLength);
956 - if ($endPos === false) {
957 - return null;
958 - }
959 -
960 - $length = $endPos - $startPos + ($includeDelimiters ? $endLength : -$startLength);
961 - if (!$length) {
962 - return '';
963 - }
964 -
965 - $offset = $startPos + ($includeDelimiters ? 0 : $startLength);
966 -
967 - return substr($string, $offset, $length);
968 - }
969 -
970 -
971 -
972 -
973 -
974 - /**
975 637 * Delete property via SQL
976 638 *
977 639 * @param int $deleteId The ID of the property to delete.
978 640 * @param string $ListingKey The listing key of the property.
@@ -979,8 +641,9 @@
979 641 */
980 642 public function mlsimportSaasDeletePropertyViaMysql($deleteId, $ListingKey) {
981 643 global $mlsimport;
982 644
645 + // Resolve the post's type and the theme's expected property post type.
983 646 $postType = get_post_type($deleteId);
984 647 $propertyPostType = '';
985 648 if (isset($mlsimport->admin->env_data) && method_exists($mlsimport->admin->env_data, 'get_property_post_type')) {
986 649 $propertyPostType = $mlsimport->admin->env_data->get_property_post_type();
@@ -985,10 +648,14 @@
985 648 if (isset($mlsimport->admin->env_data) && method_exists($mlsimport->admin->env_data, 'get_property_post_type')) {
986 649 $propertyPostType = $mlsimport->admin->env_data->get_property_post_type();
987 650 }
988 651
652 + // Only delete when the post is actually a property post type.
989 653 if ($postType === $propertyPostType || in_array($postType, ['estate_property', 'property'])) {
990 - // 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.
991 658 $attachments = get_posts([
992 659 'numberposts' => -1,
993 660 'post_type' => 'attachment',
994 661 'post_parent' => $deleteId,
@@ -995,26 +662,74 @@
995 662 'post_status' => null,
996 663 'fields' => 'ids',
997 664 ]);
998 665
999 - foreach ($attachments as $attachmentId) {
1000 - wp_delete_attachment($attachmentId, true);
1001 - }
1002 -
666 + // Capture the current status term names for the delete log.
1003 667 $termObjList = get_the_terms($deleteId, 'property_status');
1004 - $deleteIdStatus = join(', ', wp_list_pluck($termObjList, 'name'));
668 + $deleteIdStatus = is_array($termObjList) ? join(', ', wp_list_pluck($termObjList, 'name')) : '';
1005 669
1006 - $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);
1007 673 if ('' === $ListingKey) { // manually added listing
674 + // Never delete user-created listings; log and bail.
1008 675 $logEntry = 'User added listing with id ' . $deleteId . ' (' . $postType . ') (status ' . $deleteIdStatus . ') and ' . $ListingKey . ' NOT DELETED' . PHP_EOL;
1009 676 $this->writeImportLogs($logEntry, 'delete');
1010 677 return;
1011 678 }
1012 679
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));
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 +
1013 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.
1014 705 $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->postmeta WHERE `post_id` = %d", $deleteId));
1015 - $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE `post_parent` = %d OR `ID` = %d", $deleteId, $deleteId));
706 + $postsDeleted = $wpdb->query($wpdb->prepare("DELETE FROM $wpdb->posts WHERE (`post_parent` = %d AND `post_type` != 'attachment') OR `ID` = %d", $deleteId, $deleteId));
1016 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 +
1017 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;
1018 733 $this->writeImportLogs($logEntry, 'delete');
1019 734 }
1020 735 }
@@ -1027,247 +742,58 @@
1027 742
1028 743
1029 744
1030 745 /**
1031 - * Prepare to import per item
746 + * Delegate one incoming property to the explicit Stored Listing Write module.
1032 747 *
1033 - * @param array $property The property data.
1034 - * @param array $itemIdArray The item ID array.
1035 - * @param string $tipImport The import type.
1036 - * @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.
1037 757 */
1038 -public function mlsimportSaasPrepareToImportPerItem($property, $itemIdArray, $tipImport, $mlsimportItemOptionData) {
1039 - // Pre-execution memory optimization
1040 - wp_cache_flush();
1041 - gc_collect_cycles();
1042 -
1043 - // Temporarily disable WordPress hooks that might add to memory usage
1044 - global $wp_filter;
1045 - $saved_filters = array();
1046 - if (isset($wp_filter['transition_post_status'])) {
1047 - $saved_filters['transition_post_status'] = $wp_filter['transition_post_status'];
1048 - 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;
1049 770 }
1050 - if (isset($wp_filter['save_post'])) {
1051 - $saved_filters['save_post'] = $wp_filter['save_post'];
1052 - $wp_filter['save_post'] = new WP_Hook();
1053 - }
1054 - set_time_limit(0);
1055 - global $mlsimport;
1056 771
1057 - // Log initial memory
1058 - $memStart = memory_get_usage(true);
1059 - $memStartMB = round($memStart / 1048576, 2);
1060 - //error_log("PROPERTY IMPORT START - ListingKey: " . ($property['ListingKey'] ?? 'unknown') . " - Memory: {$memStartMB} MB");
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 + );
1061 794
1062 - $mlsImportItemStatus = $mlsimportItemOptionData['mlsimport_item_standardstatus'];
1063 - $mlsImportItemStatusDelete = $mlsimportItemOptionData['mlsimport_item_standardstatusdelete'];
1064 - $newAuthor = $mlsimportItemOptionData['mlsimport_item_property_user'];
1065 - $newAgent = $mlsimportItemOptionData['mlsimport_item_agent'];
1066 - $propertyStatus = $mlsimportItemOptionData['mlsimport_item_property_status'];
1067 -
1068 - if (is_array($mlsImportItemStatus)) {
1069 - $mlsImportItemStatus = array_map('strtolower', $mlsImportItemStatus);
1070 - }
1071 -
1072 - if (!isset($property['ListingKey']) || empty($property['ListingKey'])) {
1073 - $this->writeImportLogs('ERROR: No Listing Key ' . PHP_EOL, $tipImport);
1074 - return;
1075 - }
1076 -
1077 - ob_start();
1078 -
1079 - $ListingKey = $property['ListingKey'];
1080 - $listingPostType = $mlsimport->admin->env_data->get_property_post_type();
1081 -
1082 - // Memory before property ID lookup
1083 - $memBeforeRetrieve = memory_get_usage(true);
1084 - //error_log("PROPERTY IMPORT - Before property ID lookup - Memory: " . round($memBeforeRetrieve / 1048576, 2) . " MB");
1085 -
1086 - $propertyId = intval($this->mlsimportSaasRetrievePropertyById($ListingKey, $listingPostType));
1087 -
1088 - // Memory after property ID lookup
1089 - $memAfterRetrieve = memory_get_usage(true);
1090 - //error_log("PROPERTY IMPORT - After property ID lookup - Memory: " . round($memAfterRetrieve / 1048576, 2) . " MB, Diff: " . round(($memAfterRetrieve - $memBeforeRetrieve) / 1048576, 2) . " MB");
1091 -
1092 - $status = isset($property['StandardStatus']) ? strtolower($property['StandardStatus']) : strtolower($property['extra_meta']['MlsStatus']);
1093 -
1094 - $this->writeImportLogs('FIxing: on inserting ' .$status.'-->'.json_encode($mlsImportItemStatus). PHP_EOL, $tipImport);
1095 -
1096 - $isInsert = $this->shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport);
1097 -
1098 - $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;
1099 - $this->writeImportLogs($log, $tipImport);
1100 -
1101 - $propertyHistory = [];
1102 - $content = $property['content'] ?? '';
1103 - $submitTitle = $ListingKey;
1104 -
1105 - // Memory before insert/update
1106 - $memBeforeInsert = memory_get_usage(true);
1107 - //error_log("PROPERTY IMPORT - Before " . ($isInsert === 'yes' ? "insert" : "update/check") . " - Memory: " . round($memBeforeInsert / 1048576, 2) . " MB");
1108 -
1109 - if ($isInsert === 'yes') {
1110 - $post = [
1111 - 'post_title' => $submitTitle,
1112 - 'post_content' => $content,
1113 - 'post_status' => $propertyStatus,
1114 - 'post_type' => $listingPostType,
1115 - 'post_author' => $newAuthor,
1116 - ];
1117 -
1118 - $propertyId = wp_insert_post($post);
1119 - if (is_wp_error($propertyId)) {
1120 - $this->writeImportLogs('ERROR: on inserting ' . PHP_EOL, $tipImport);
1121 - //error_log("PROPERTY IMPORT ERROR - Failed to insert property: " . $propertyId->get_error_message());
1122 - } else {
1123 - update_post_meta($propertyId, 'ListingKey', $ListingKey);
1124 - update_post_meta($propertyId, 'MLSimport_item_inserted', $itemIdArray['item_id'],);
1125 - update_post_meta($propertyId, 'mlsImportItemStatusDelete', $mlsImportItemStatusDelete);
1126 -
1127 - $propertyHistory[] = date('F j, Y, g:i a') . ': We Inserted the property with Default title : ' . $submitTitle . ' and received id:' . $propertyId;
1128 - }
1129 -
1130 - clean_post_cache($propertyId);
1131 -
1132 - } elseif ($propertyId !== 0) {
1133 - // Memory before checking existing property
1134 - $memBeforeCheck = memory_get_usage(true);
1135 - //error_log("PROPERTY IMPORT - Before checking existing property - Memory: " . round($memBeforeCheck / 1048576, 2) . " MB");
1136 -
1137 - $keep = $this->check_if_delete_when_status($propertyId,$mlsImportItemStatus,$mlsImportItemStatusDelete);
1138 -
1139 -
1140 - if(!$keep){
1141 - $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;
1142 -
1143 - // Memory before delete
1144 - $memBeforeDelete = memory_get_usage(true);
1145 - //error_log("PROPERTY IMPORT - Before deleting property - Memory: " . round($memBeforeDelete / 1048576, 2) . " MB");
1146 -
1147 - $this->deleteProperty($propertyId, $ListingKey);
1148 -
1149 - // Memory after delete
1150 - $memAfterDelete = memory_get_usage(true);
1151 - //error_log("PROPERTY IMPORT - After deleting property - Memory: " . round($memAfterDelete / 1048576, 2) . " MB, Diff: " . round(($memAfterDelete - $memBeforeDelete) / 1048576, 2) . " MB");
1152 -
1153 - $this->writeImportLogs($log, $tipImport);
1154 - } else {
1155 - update_post_meta($propertyId, 'mlsImportItemStatusDelete', $mlsImportItemStatusDelete);
1156 -
1157 - // Memory before updating
1158 - $memBeforeUpdate = memory_get_usage(true);
1159 - //error_log("PROPERTY IMPORT - Before updating existing property - Memory: " . round($memBeforeUpdate / 1048576, 2) . " MB");
1160 -
1161 - $propertyHistory = $this->updateExistingProperty($propertyId,$mlsImportItemStatusDelete, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, $propertyHistory, $tipImport, $ListingKey);
1162 -
1163 - // Memory after updating
1164 - $memAfterUpdate = memory_get_usage(true);
1165 - //error_log("PROPERTY IMPORT - After updating existing property - Memory: " . round($memAfterUpdate / 1048576, 2) . " MB, Diff: " . round(($memAfterUpdate - $memBeforeUpdate) / 1048576, 2) . " MB");
1166 - }
1167 - }
1168 -
1169 - // Memory after insert/update
1170 - $memAfterInsert = memory_get_usage(true);
1171 - //error_log("PROPERTY IMPORT - After " . ($isInsert === 'yes' ? "insert" : "update/check") . " - Memory: " . round($memAfterInsert / 1048576, 2) . " MB, Diff: " . round(($memAfterInsert - $memBeforeInsert) / 1048576, 2) . " MB");
1172 -
1173 - if ($propertyId === 0) {
1174 - $this->writeImportLogs('ERROR property id is 0' . PHP_EOL, $tipImport);
1175 - return;
1176 - }
1177 -
1178 - // Memory before processing details
1179 - $memBeforeDetails = memory_get_usage(true);
1180 - //error_log("PROPERTY IMPORT - Before processing property details - Memory: " . round($memBeforeDetails / 1048576, 2) . " MB");
1181 -
1182 - $newTitle = $this->processPropertyDetails($property, $propertyId, $tipImport, $propertyHistory, $newAgent, $itemIdArray,$isInsert);
1183 -
1184 - // Memory after processing details
1185 - $memAfterDetails = memory_get_usage(true);
1186 - //error_log("PROPERTY IMPORT - After processing property details - Memory: " . round($memAfterDetails / 1048576, 2) . " MB, Diff: " . round(($memAfterDetails - $memBeforeDetails) / 1048576, 2) . " MB");
1187 -
1188 - $log = PHP_EOL . 'Ending on Property ' . $propertyId . ', ListingKey: ' . $ListingKey . ' , is insert? ' . $isInsert . ' with new title: ' . $newTitle . ' ' . PHP_EOL;
1189 - $this->writeImportLogs($log, $tipImport);
1190 -
1191 - clean_post_cache($propertyId);
1192 -
1193 - // More aggressive memory cleanup
1194 - // First clear specific large arrays in property data
1195 - if (isset($property['Media']) && is_array($property['Media'])) {
1196 - foreach ($property['Media'] as $key => $media) {
1197 - unset($property['Media'][$key]);
1198 - }
1199 - }
1200 - if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1201 - foreach ($property['extra_meta'] as $key => $value) {
1202 - unset($property['extra_meta'][$key]);
1203 - }
1204 - }
1205 - if (isset($property['meta']) && is_array($property['meta'])) {
1206 - foreach ($property['meta'] as $key => $value) {
1207 - unset($property['meta'][$key]);
1208 - }
1209 - }
1210 - if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1211 - foreach ($property['taxonomies'] as $key => $value) {
1212 - unset($property['taxonomies'][$key]);
1213 - }
1214 - }
1215 -
1216 - // Then unset the main arrays
1217 - unset($property['Media']);
1218 - unset($property['extra_meta']);
1219 - unset($property['meta']);
1220 - unset($property['taxonomies']);
1221 - unset($property);
1222 -
1223 - // Clear any post caches that might have been created
1224 - clean_post_cache($propertyId);
1225 -
1226 - // Clear other variables that hold large data
1227 - unset($log);
1228 - unset($propertyHistory);
1229 - $GLOBALS['wpdb']->queries = array();
1230 -
1231 - // Clear WordPress specific caches
1232 - wp_cache_delete('get_term_meta', 'terms');
1233 - wp_cache_delete('terms', 'terms');
1234 - wp_cache_delete('term_meta', 'terms');
1235 - wp_cache_delete('get_terms', 'terms');
1236 -
1237 - // Clear post related caches
1238 - wp_cache_delete('post_meta_' . $propertyId, 'post_meta');
1239 - wp_cache_delete($propertyId, 'posts');
1240 -
1241 - // Force multiple garbage collection cycles
1242 - gc_collect_cycles();
1243 - gc_collect_cycles();
1244 -
1245 - // Close and discard any output buffer content
1246 - ob_end_clean();
1247 -
1248 - // Try to trigger PHP's internal memory cleanup
1249 - $dummy = str_repeat('x', 1024 * 1024);
1250 - unset($dummy);
1251 -
1252 - // Final memory usage
1253 - $memEnd = memory_get_usage(true);
1254 - $memEndMB = round($memEnd / 1048576, 2);
1255 - $memDiff = round(($memEnd - $memStart) / 1048576, 2);
1256 - //error_log("PROPERTY IMPORT COMPLETE - ListingKey: {$ListingKey} - Final Memory: {$memEndMB} MB, Total Change: {$memDiff} MB");
1257 -
1258 - // If we see a significant memory increase, log a warning
1259 - if ($memDiff > 5) {
1260 - //error_log("WARNING: Significant memory increase of {$memDiff} MB after processing property {$ListingKey}");
1261 - }
1262 -
1263 - // Restore WordPress hooks
1264 - global $wp_filter;
1265 - if (!empty($saved_filters)) {
1266 - foreach ($saved_filters as $hook => $filter) {
1267 - $wp_filter[$hook] = $filter;
1268 - }
1269 - }
795 + return $this->stored_listing_write->write( $property, $settings );
1270 796 }
1271 797
1272 798
1273 799
@@ -1273,118 +799,86 @@
1273 799
1274 800
1275 801
1276 802
1277 - /**
1278 - * Check if the property should be inserted
1279 - *
1280 - * @param int $propertyId The property ID.
1281 - * @param string $status The property status.
1282 - * @param array $mlsImportItemStatus The MLS import item status.
1283 - * @param string $tipImport The import type.
1284 - * @return string 'yes' or 'no' indicating if the property should be inserted.
1285 - */
1286 - private function shouldInsertProperty($propertyId, $status, $mlsImportItemStatus, $tipImport): string{
1287 - $this->writeImportLogs(
1288 - "Checking: on inserting {$propertyId}={$status} vs " .
1289 - json_encode($mlsImportItemStatus) . " -- {$tipImport}" . PHP_EOL,
1290 - $tipImport
1291 - );
1292 803
1293 - if ($propertyId !== 0 || !is_array($mlsImportItemStatus)) {
1294 - //error_log('mlsImportItemStatus 0 ');
1295 - return 'no';
1296 -
1297 - }
1298 -
1299 - $activeStatuses = [
1300 - 'active',
1301 - 'active under contract',
1302 - 'active with contract',
1303 - 'activewithcontract',
1304 - 'status',
1305 - 'activeundercontract',
1306 - 'comingsoon',
1307 - 'coming soon',
1308 - 'pending'
1309 - ];
1310 - if(is_array($mlsImportItemStatus)){
1311 - if (!in_array(strtolower($status), $mlsImportItemStatus, true)) {
1312 - //error_log('mlsImportItemStatus 1 ');
1313 - return 'no';
1314 - }
1315 804
1316 - if ($tipImport === 'cron' && !in_array($status, $mlsImportItemStatus, true)) {
1317 - //error_log('mlsImportItemStatus 2');
1318 - return 'no';
1319 - }
1320 -
1321 - }else{
1322 - if(!in_array($status, $activeStatuses, true) ){
1323 - //error_log('mlsImportItemStatus 3 ');
1324 - return 'no';
1325 - }
1326 - }
1327 -
1328 - return 'yes';
1329 - }
1330 -
1331 -
1332 805 /**
1333 806 * Check for property status against MLS item delete status to see if we keep or delete the listing.
1334 807 * @param int $property_id
1335 808 * @param string|array $mlsImportItemStatus
1336 - * @param string|array $mlsImportItemStatusDelete
1337 809 * @return bool True to keep, false to delete
1338 810 */
1339 -public function check_if_delete_when_status($property_id, $mlsImportItemStatus, $mlsImportItemStatusDelete) {
1340 - // Normalize status arrays/strings to lowercase
1341 - $mlsImportItemStatus = is_array($mlsImportItemStatus)
1342 - ? array_map('strtolower', $mlsImportItemStatus)
1343 - : strtolower($mlsImportItemStatus);
811 +public function check_if_delete_when_status($property_id, $mlsImportItemStatus, $mlsImportItemStatusDelete = null, $mlsImportItemStatusProtect = null) {
1344 812
1345 - $mlsImportItemStatusDelete = is_array($mlsImportItemStatusDelete)
1346 - ? array_map('strtolower', $mlsImportItemStatusDelete)
1347 - : strtolower($mlsImportItemStatusDelete);
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);
1348 818
1349 - // Get post_status based on post type/taxonomy
1350 - $post_status = '';
1351 - if (post_type_exists('estate_property')) {
1352 - $terms = get_the_terms($property_id, 'property_status');
1353 - if (!empty($terms) && is_array($terms)) {
1354 - $post_status = strtolower($terms[0]->name);
819 + // Protected statuses: keep if property status matches
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;
1355 825 }
1356 - } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1357 - $terms = get_the_terms($property_id, 'property_label');
1358 - if (!empty($terms) && is_array($terms)) {
1359 - $post_status = strtolower($terms[0]->name);
826 + // Normalise the protect list to space-free enum keys.
827 + $mlsImportItemStatusProtect = is_array($mlsImportItemStatusProtect)
828 + ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatusProtect)
829 + : array(mlsimport_normalize_status_enum($mlsImportItemStatusProtect));
830 + // Property status is protected → keep it.
831 + if (in_array($post_status, $mlsImportItemStatusProtect, true)) {
832 + return true;
1360 833 }
1361 - } else {
1362 - $post_status = strtolower(get_post_meta($property_id, 'inspiry_property_label', true));
1363 834 }
1364 835
1365 - // //error_log for debugging (can comment out in production)
1366 - // //error_log("[MLSImport] post_status: {$post_status} | keep_status: " . json_encode($mlsImportItemStatus) . " | delete_status: " . json_encode($mlsImportItemStatusDelete));
836 + // Default: delete if not protected
837 + return false;
838 +}
1367 839
1368 - // Keep if status matches "keep" status
1369 - if ((is_array($mlsImportItemStatus) && in_array($post_status, $mlsImportItemStatus, true)) ||
1370 - (!is_array($mlsImportItemStatus) && $post_status === $mlsImportItemStatus)) {
840 +
841 +
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 + */
850 +public function check_if_delete_when_status_on_manual_import($property_id, $mlsImportItemStatus) {
851 + // Normalize status arrays/strings to a space-free comparison key so
852 + // Trestle PrettyEnums labels match the raw enum config values.
853 + $mlsImportItemStatus = is_array($mlsImportItemStatus)
854 + ? array_map('mlsimport_normalize_status_enum', $mlsImportItemStatus)
855 + : mlsimport_normalize_status_enum($mlsImportItemStatus);
856 +
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');
1371 866 return true;
1372 867 }
1373 868
1374 - // Delete if status matches "delete" status
1375 - if (!empty($mlsImportItemStatusDelete)) {
1376 - if ((is_array($mlsImportItemStatusDelete) && in_array($post_status, $mlsImportItemStatusDelete, true)) ||
1377 - (!is_array($mlsImportItemStatusDelete) && $post_status === $mlsImportItemStatusDelete)) {
1378 - return false;
1379 - }
869 + // Keep if status matches "keep" status (array membership or scalar equality).
870 + if ((is_array($mlsImportItemStatus) && in_array($post_status, $mlsImportItemStatus, true)) ||
871 + (!is_array($mlsImportItemStatus) && $post_status === $mlsImportItemStatus)) {
872 +
873 + return true;
1380 874 }
1381 875
1382 - // Default: keep
1383 - return true;
1384 -}
1385 876
1386 877
878 + // Default: status read but doesn't match the task's selection → delete.
879 + return false;
880 +}
1387 881
1388 882
1389 883
1390 884
@@ -1392,33 +886,35 @@
1392 886
1393 887
1394 888 /**
1395 889 * Check if we should keep or delete the listing when still in MLS.
890 + * true we keep
1396 891 */
1397 - public function check_if_delete_when_status_when_in_mls($property_id,$mlsimport_item_standardstatus) {
1398 - // Get the inserted item and its MLS standard status
892 + public function check_if_delete_when_status_when_in_mls($property_id, $mlsimport_item_standardstatus, $mlsimport_item_standardstatusprotect = null) {
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);
1399 898
1400 -
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;
905 + }
1401 906
1402 - // Default to false
1403 - $post_status = '';
1404 -
1405 - // Check for post status based on post type/taxonomy
1406 - if (post_type_exists('estate_property')) {
1407 - // WPResidence
1408 - $terms = get_the_terms($property_id, 'property_status');
1409 - if (!empty($terms) && is_array($terms)) {
1410 - $post_status = $terms[0]->name;
907 + // Protected statuses: keep if property status matches
908 + if (!empty($mlsimport_item_standardstatusprotect)) {
909 + // Normalise the protect list to space-free enum keys.
910 + $mlsimport_item_standardstatusprotect = is_array($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.
914 + if (in_array($post_status, $mlsimport_item_standardstatusprotect, true)) {
915 + return true;
1411 916 }
1412 - } elseif (post_type_exists('property') && taxonomy_exists('property_label')) {
1413 - // Houzez
1414 - $terms = get_the_terms($property_id, 'property_label');
1415 - if (!empty($terms) && is_array($terms)) {
1416 - $post_status = $terms[0]->name;
1417 - }
1418 - } else {
1419 - // RealHomes
1420 - $post_status = get_post_meta($property_id, 'inspiry_property_label', true);
1421 917 }
1422 918
1423 919 // Early return if MLS status empty
1424 920 if (empty($mlsimport_item_standardstatus)) {
@@ -1424,13 +920,16 @@
1424 920 if (empty($mlsimport_item_standardstatus)) {
1425 921 return true; // default: keep if no status set
1426 922 }
1427 923
1428 - // If it's an array, check for post status in it; otherwise, compare string
924 + // Normalize standard statuses to a space-free key for comparison
1429 925 if (is_array($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);
1430 928 return in_array($post_status, $mlsimport_item_standardstatus, true);
1431 929 }
1432 - return $post_status === $mlsimport_item_standardstatus;
930 + // Scalar form → keep on exact (normalised) match.
931 + return $post_status === mlsimport_normalize_status_enum($mlsimport_item_standardstatus);
1433 932 }
1434 933
1435 934
1436 935
@@ -1437,361 +936,11 @@
1437 936
1438 937
1439 938
1440 939
1441 - /**
1442 - * Update existing property
1443 - *
1444 - * @param int $propertyId The property ID.
1445 - * @param string $content The post content.
1446 - * @param string $listingPostType The listing post type.
1447 - * @param int $newAuthor The new author ID.
1448 - * @param string $status The property status.
1449 - * @param array $mlsImportItemStatus The MLS import item status.
1450 - * @param array $propertyHistory The property history.
1451 - * @param string $tipImport The import type.
1452 - * @param string $ListingKey The listing key.
1453 - * @return array Updated property history.
1454 - */
1455 - private function updateExistingProperty($propertyId,$mlsImportItemStatusDelete, $content, $listingPostType, $newAuthor, $status, $mlsImportItemStatus, &$propertyHistory, $tipImport, $ListingKey) {
1456 -
1457 -
1458 - $post = [
1459 - 'ID' => $propertyId,
1460 - 'post_content' => $content,
1461 - 'post_type' => $listingPostType,
1462 - 'post_author' => $newAuthor,
1463 - ];
1464 940
1465 - $log = 'Property with ID ' . $propertyId . ' and with name ' . get_the_title($propertyId) . ' has a status of <strong>' . $status . '</strong> and will be Edited</br>';
1466 - $this->writeImportLogs($log, $tipImport);
1467 941
1468 - $propertyId = wp_update_post($post);
1469 - if (is_wp_error($propertyId)) {
1470 - $this->writeImportLogs('ERROR: on edit ' . PHP_EOL, $tipImport);
1471 - } else {
1472 - $submitTitle = get_the_title($propertyId);
1473 - $propertyHistory[] = gmdate('F j, Y, g:i a') . ': Property with title: ' . $submitTitle . ', id:' . $propertyId . ', ListingKey:' . $ListingKey . ', Status:' . $status . ' will be edited';
1474 - }
1475 - clean_post_cache( $propertyId );
1476 -
1477 - return $propertyHistory;
1478 - }
1479 942
1480 -/**
1481 - * Process property details with memory tracking and optimization
1482 - *
1483 - * @param array $property The property data.
1484 - * @param int $propertyId The property ID.
1485 - * @param string $tipImport The import type.
1486 - * @param array $propertyHistory The property history.
1487 - * @param int $newAgent The new agent ID.
1488 - * @param array $itemIdArray The item ID array.
1489 - * @param string $isInsert If is a property insert
1490 - */
1491 -private function processPropertyDetails($property, $propertyId, $tipImport, &$propertyHistory, $newAgent, $itemIdArray, $isInsert) {
1492 - global $mlsimport, $wpdb;
1493 -
1494 - //error_log("Property Data: " . print_r($property, true));
1495 -
1496 - // Normalize timestamp fields in extra_meta to format like "May 17, 2025 at 06:26am"
1497 - if (isset($property['extra_meta']) && is_array($property['extra_meta'])) {
1498 - $timestampFields = [
1499 - 'StatusChangeTimestamp',
1500 - 'STELLAR_BOMDate',
1501 - 'PriceChangeTimestamp',
1502 - 'PhotosChangeTimestamp',
1503 - 'BridgeModificationTimestamp',
1504 - 'ModificationTimestamp',
1505 - 'OriginalEntryTimestamp',
1506 - 'MajorChangeTimestamp'
1507 - ];
1508 -
1509 - foreach ($timestampFields as $tsField) {
1510 - if (!empty($property['extra_meta'][$tsField])) {
1511 - $timestamp = strtotime($property['extra_meta'][$tsField]);
1512 - if ($timestamp !== false) {
1513 - $property['extra_meta'][$tsField] = gmdate('F j, Y \a\t h:ia', $timestamp);
1514 - }
1515 - }
1516 - }
1517 - }
1518 -
1519 -
1520 -
1521 - // 1. DISABLE AUTOCOMMIT FOR BATCH PROCESSING
1522 - // This reduces memory by preventing DB auto-commits between operations
1523 - if (method_exists($wpdb, 'query')) {
1524 - $wpdb->query('SET autocommit = 0');
1525 - }
1526 -
1527 - // 2. TEMPORARY DISABLE ACTIONS THAT CONSUME MEMORY
1528 - $suspended_actions = [];
1529 - foreach (['save_post', 'added_post_meta', 'updated_post_meta'] as $action) {
1530 - if (has_action($action)) {
1531 - $suspended_actions[$action] = true;
1532 - remove_all_actions($action);
1533 - }
1534 - }
1535 -
1536 - // Initial memory
1537 - $memStart = memory_get_usage(true);
1538 - //error_log("PROPERTY DETAILS START - Property ID: {$propertyId} - Initial Memory: " . round($memStart / 1048576, 2) . " MB");
1539 -
1540 - $log = PHP_EOL . $this->mlsimportMemUsage() . '====before tax======' . PHP_EOL;
1541 - $this->writeImportLogs($log, $tipImport);
1542 -
1543 - // 3. OPTIMIZE TAXONOMY PROCESSING
1544 - if (isset($property['taxonomies']) && is_array($property['taxonomies'])) {
1545 - $memBeforeTax = memory_get_usage(true);
1546 - //error_log("PROPERTY DETAILS - Before taxonomy processing - Memory: " . round($memBeforeTax / 1048576, 2) . " MB");
1547 -
1548 - // Load taxonomy mapping options
1549 - $options = get_option('mlsimport_admin_fields_select');
1550 - $theme_schema = mlsimport_hardocde_theme_schema();
1551 - $taxonomy_overrides = array();
1552 - if (isset($options['mls-fields-map-taxonomy']) && is_array($options['mls-fields-map-taxonomy'])) {
1553 - foreach ($options['mls-fields-map-taxonomy'] as $field_key => $mapped_tax) {
1554 - if ($mapped_tax === '') {
1555 - continue;
1556 - }
1557 - if (isset($theme_schema[$field_key]) && isset($theme_schema[$field_key]['type']) &&
1558 - $theme_schema[$field_key]['type'] === 'taxonomy' && isset($theme_schema[$field_key]['name'])) {
1559 - $default_tax = $theme_schema[$field_key]['name'];
1560 - if ($default_tax !== $mapped_tax) {
1561 - $taxonomy_overrides[$default_tax] = $mapped_tax;
1562 - }
1563 - }
1564 - }
1565 - }
1566 -
1567 - // Disable term counting temporarily (major memory saver)
1568 - wp_defer_term_counting(true);
1569 -
1570 - remove_filter('get_term_metadata', 'lazyload_term_meta', 10);
1571 - wp_cache_delete('get_ancestors', 'taxonomy');
1572 -
1573 - // Clear existing taxonomies
1574 - $this->mlsimportSaasClearPropertyForTaxonomy($propertyId, $property['taxonomies']);
1575 -
1576 - // 4. PROCESS TAXONOMIES IN CHUNKS
1577 - $taxChunks = array_chunk($property['taxonomies'], 5, true);
1578 - foreach ($taxChunks as $taxChunk) {
1579 - foreach ($taxChunk as $taxonomy => $term) {
1580 - if (isset($taxonomy_overrides[$taxonomy])) {
1581 - $taxonomy = $taxonomy_overrides[$taxonomy];
1582 - }
1583 - wp_cache_delete("{$taxonomy}_term_counts", 'counts');
1584 - $this->mlsimportSaasUpdateTaxonomyForProperty($taxonomy, $propertyId, $term);
1585 - $propertyHistory[] = 'Updated Taxonomy ' . $taxonomy . ' with terms ' . wp_json_encode($term);
1586 -
1587 - // Memory cleanup after each taxonomy
1588 - wp_cache_delete('term_meta', 'terms');
1589 - wp_cache_delete($taxonomy, 'terms');
1590 - }
1591 -
1592 - // 5. FORCE GC AFTER EACH CHUNK
1593 - gc_collect_cycles();
1594 - }
1595 -
1596 - // Restore term filter and clean up
1597 - add_filter('get_term_metadata', 'lazyload_term_meta', 10, 2);
1598 - delete_option('category_children');
1599 -
1600 - // Re-enable term counting
1601 - wp_defer_term_counting(false);
1602 -
1603 - $memAfterTax = memory_get_usage(true);
1604 - //error_log("PROPERTY DETAILS - After all taxonomy processing - Memory: " . round($memAfterTax / 1048576, 2) .
1605 - // " MB, Total Diff: " . round(($memAfterTax - $memBeforeTax) / 1048576, 2) . " MB");
1606 - }
1607 -
1608 - // 6. FLUSH SPECIFIC CACHES INSTEAD OF ALL
1609 - // More targeted than wp_cache_flush()
1610 - wp_cache_delete('terms', 'terms');
1611 - wp_cache_delete('term_meta', 'terms');
1612 - wp_cache_delete("post_meta_{$propertyId}", 'post_meta');
1613 - wp_cache_delete($propertyId, 'posts');
1614 -
1615 - // Prepare meta data
1616 - $property = $this->mlsimportSaasPrepareMetaForProperty($property);
1617 -
1618 - // 7. BATCH META UPDATES
1619 - if (isset($property['meta']) && is_array($property['meta'])) {
1620 - $memBeforeMeta = memory_get_usage(true);
1621 - $metaCount = count($property['meta']);
1622 -
1623 - // Use direct SQL for batch meta updates if many fields
1624 - if ($metaCount > 20 && method_exists($wpdb, 'prepare')) {
1625 - $meta_values = [];
1626 - foreach ($property['meta'] as $metaName => $metaValue) {
1627 - if (is_array($metaValue)) {
1628 - $metaValue = implode(',', $metaValue);
1629 - }
1630 -
1631 - // Build history separately
1632 - $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1633 -
1634 - // First delete existing
1635 - $wpdb->delete(
1636 - $wpdb->postmeta,
1637 - ['post_id' => $propertyId, 'meta_key' => $metaName],
1638 - ['%d', '%s']
1639 - );
1640 -
1641 - // Collect for batch insert
1642 - $meta_values[] = $wpdb->prepare(
1643 - "(%d, %s, %s)",
1644 - $propertyId,
1645 - $metaName,
1646 - $metaValue
1647 - );
1648 - }
1649 -
1650 - // Batch insert all meta at once
1651 - if (!empty($meta_values)) {
1652 - $wpdb->query("INSERT INTO {$wpdb->postmeta} (post_id, meta_key, meta_value) VALUES " .
1653 - implode(", ", $meta_values));
1654 - }
1655 - } else {
1656 - // Standard approach for fewer meta fields
1657 - foreach ($property['meta'] as $metaName => $metaValue) {
1658 - if (is_array($metaValue)) {
1659 - $metaValue = implode(',', $metaValue);
1660 - }
1661 - update_post_meta($propertyId, $metaName, $metaValue);
1662 - $propertyHistory[] = 'Updated Meta ' . $metaName . ' with meta_value ' . $metaValue;
1663 - }
1664 - }
1665 -
1666 - $memAfterMeta = memory_get_usage(true);
1667 - //error_log("PROPERTY DETAILS - After meta processing - Memory: " . round($memAfterMeta / 1048576, 2) .
1668 - // " MB, Diff: " . round(($memAfterMeta - $memBeforeMeta) / 1048576, 2) . " MB");
1669 - }
1670 -
1671 - // Extra meta processing
1672 - $extraMetaResult = $mlsimport->admin->env_data->mlsimportSaasSetExtraMeta($propertyId, $property);
1673 - if (isset($extraMetaResult['property_history'])) {
1674 - $propertyHistory = array_merge($propertyHistory, (array)$extraMetaResult['property_history']);
1675 - }
1676 -
1677 - // 8. PROCESS MEDIA IN CHUNKS
1678 - $memBeforeMedia = memory_get_usage(true);
1679 -
1680 -
1681 - if (isset($property['Media']) && is_array($property['Media'])) {
1682 - $media_attachments=array();
1683 -
1684 - $mediaCount = count($property['Media']);
1685 - //error_log("PROPERTY DETAILS - Processing {$mediaCount} media items");
1686 -
1687 - // Process in chunks of 5
1688 - $mediaChunks = array_chunk($property['Media'], 5);
1689 - $mediaHistoryParts = [];
1690 -
1691 - // Clear original array to free memory
1692 - $originalMedia = $property['Media'];
1693 - unset($property['Media']);
1694 -
1695 - if ($isInsert !== 'no') {
1696 - delete_post_meta($propertyId, 'fave_property_images');
1697 - delete_post_meta($propertyId, 'REAL_HOMES_property_images');
1698 - delete_post_meta($propertyId, 'wpestate_property_gallery');
1699 - //error_log("MLSImport: Deleted existing image meta for property ID: $propertyId");
1700 - }
1701 -
1702 -
1703 - foreach ($mediaChunks as $index => $mediaChunk) {
1704 - $media_attachments = $this->mlsimportSassAttachMediaToPost($propertyId, $mediaChunk, $isInsert,$media_attachments);
1705 - // $mediaHistoryParts[] = $chunkHistory;
1706 -
1707 - // Free memory
1708 - unset($mediaChunk);
1709 - //unset($chunkHistory);
1710 - gc_collect_cycles();
1711 -
1712 - // Incremental progress report
1713 - //error_log("PROPERTY DETAILS - Processed media chunk " . ($index + 1) . "/" . count($mediaChunks));
1714 - }
1715 -
1716 -
1717 - $mlsimport->admin->env_data->enviroment_image_save_gallery($propertyId, $media_attachments);
1718 -
1719 - // Combine all chunks
1720 - // $mediaHistory = implode('</br>', $mediaHistoryParts);
1721 - // $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
1722 -
1723 - // Clean up
1724 - unset($mediaChunks);
1725 - unset($mediaHistoryParts);
1726 - unset($mediaHistory);
1727 - unset($originalMedia);
1728 - } else {
1729 - $mediaHistory = $this->mlsimportSassAttachMediaToPost($propertyId, $property['Media'] ?? [], $isInsert);
1730 - $propertyHistory = array_merge($propertyHistory, (array)$mediaHistory);
1731 - }
1732 -
1733 -
1734 - $memAfterMedia = memory_get_usage(true);
1735 - //error_log("PROPERTY DETAILS - After media processing - Memory: " . round($memAfterMedia / 1048576, 2) .
1736 - // " MB, Diff: " . round(($memAfterMedia - $memBeforeMedia) / 1048576, 2) . " MB");
1737 -
1738 - // Update title
1739 - $newTitle = $this->mlsimportSaasUpdatePropertyTitle($propertyId, $itemIdArray['item_id'], $property);
1740 - $propertyHistory[] = 'Updated title to ' . $newTitle . '</br>';
1741 -
1742 - // Correlation update
1743 - $mlsimport->admin->env_data->correlationUpdateAfter($isInsert, $propertyId, [], $newAgent);
1744 -
1745 - // 9. COMMIT TRANSACTION
1746 - if (method_exists($wpdb, 'query')) {
1747 - $wpdb->query('COMMIT');
1748 - $wpdb->query('SET autocommit = 1');
1749 - }
1750 -
1751 - // Save property history - using direct SQL if history is large
1752 - if (!empty($propertyHistory)) {
1753 - if (intval(get_option('mlsimport-disable-history', 1)) === 1) {
1754 - $propertyHistory[] = '---------------------------------------------------------------</br>';
1755 - $propertyHistory = implode('</br>', $propertyHistory);
1756 -
1757 - // 10. USE DIRECT SQL FOR LARGE HISTORY
1758 - if (strlen($propertyHistory) > 10000 && method_exists($wpdb, 'update')) {
1759 - $wpdb->update(
1760 - $wpdb->postmeta,
1761 - ['meta_value' => $propertyHistory],
1762 - ['post_id' => $propertyId, 'meta_key' => 'mlsimport_property_history'],
1763 - ['%s'],
1764 - ['%d', '%s']
1765 - );
1766 - } else {
1767 - update_post_meta($propertyId, 'mlsimport_property_history', $propertyHistory);
1768 - }
1769 - }
1770 - }
1771 -
1772 - // 11. RESTORE ACTIONS
1773 - if (!empty($suspended_actions)) {
1774 - foreach ($suspended_actions as $action => $true) {
1775 - add_action($action, '_wp_action_exists_' . $action);
1776 - remove_action($action, '_wp_action_exists_' . $action);
1777 - }
1778 - }
1779 -
1780 - // 12. FINAL CLEANUP
1781 - $property = null;
1782 - $propertyHistory = null;
1783 - wp_cache_flush();
1784 - gc_collect_cycles();
1785 -
1786 - // Final memory stats
1787 - $memEnd = memory_get_usage(true);
1788 - //error_log("PROPERTY DETAILS COMPLETE - Property ID: {$propertyId} - " .
1789 - // "Final Memory: " . round($memEnd / 1048576, 2) . " MB, " .
1790 - // "Total Change: " . round(($memEnd - $memStart) / 1048576, 2) . " MB");
1791 -
1792 - return $newTitle;
1793 -}
1794 943
1795 944
1796 945
1797 946